diff --git a/build/common.targets b/build/common.targets index 4a078f80..af24c8d7 100644 --- a/build/common.targets +++ b/build/common.targets @@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed. - 4.3.2 + 4.4.0 SMAPI latest $(AssemblySearchPaths);{GAC} diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets index 7829a6fb..14c4d816 100644 --- a/build/deploy-local-smapi.targets +++ b/build/deploy-local-smapi.targets @@ -39,9 +39,6 @@ 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 44987b66..837f8884 100755 --- a/build/unix/prepare-install-package.sh +++ b/build/unix/prepare-install-package.sh @@ -172,11 +172,6 @@ for folder in ${folders[@]}; do cp "$installAssets/windows-exe-config.xml" "$bundlePath/StardewModdingAPI.exe.config" fi - # copy .NET dependencies - if [ $folder == "windows" ]; then - cp "$smapiBin/System.Management.dll" "$bundlePath/smapi-internal" - fi - # copy bundled mods for modName in ${bundleModNames[@]}; do fromPath="src/SMAPI.Mods.$modName/bin/$buildConfig/$runtime/publish" diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1 index c79ac5f0..a5a6b043 100644 --- a/build/windows/prepare-install-package.ps1 +++ b/build/windows/prepare-install-package.ps1 @@ -197,11 +197,6 @@ foreach ($folder in $folders) { cp "$installAssets/windows-exe-config.xml" "$bundlePath/StardewModdingAPI.exe.config" } - # copy .NET dependencies - if ($folder -eq "windows") { - cp "$smapiBin/System.Management.dll" "$bundlePath/smapi-internal" - } - # copy bundled mods foreach ($modName in $bundleModNames) { $fromPath = "src/SMAPI.Mods.$modName/bin/$buildConfig/$runtime/publish" diff --git a/docs/release-notes.md b/docs/release-notes.md index 9d33cc97..563d5f85 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,39 @@ ← [README](README.md) # Release notes +## 4.4.0 +Released 10 January 2026 for Stardew Valley 1.6.14 or later. See [release highlights](https://www.patreon.com/posts/147916705). + +* For players: + * Added [`set_verbose` console command](https://stardewvalleywiki.com/Modding:Console_commands#set_verbose). + * The SMAPI log now shows a friendly Windows name (like "Windows 11") instead of its internal identifier. + * Fixed `player_add` and `list_items` console commands not including some newer juice items. + * Fixed farmhouse map edits sometimes removing the spouse room (thanks to SinZ!). + * Fixed installer error if Steam has an empty game path saved to the registry. + +* For mod authors: + * Added [input API to send button presses to the game](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Input#Send_input) (thanks to martiandweller!). + * Added transparency masks via `PatchMode.Mask` when editing images (thanks to PinkSerenity!). + * Added support for map tilesheets referencing an asset outside `Content/Maps` using a relative `../` path (thanks to Spiderbuttons!). + * Added asset propagation for spouse room map edits. + * Improved performance when propagating localized assets in some cases (thanks to SinZ!). + * Improved error-handling during asset propagation. + * Updated dependencies, including... + * [Newtonsoft.Json](https://www.newtonsoft.com/json) 13.0.3 → 13.0.4 (see [changes](https://github.com/JamesNK/Newtonsoft.Json/releases/tag/13.0.4)); + * [Pintail](https://github.com/Nanoray-pl/Pintail) 2.8.1 → 2.9.1 (see [changes](https://github.com/Nanoray-pl/Pintail/blob/master/docs/release-notes.md#291)). + * Fixed asset propagation for farmer sprites before a save is loaded. + * Removed `System.Management.dll`, which SMAPI no longer uses. + +* For the web UI: + * Improved mod compatibility list: + * Added support for mod links in warnings. + * Improved Content Patcher [JSON schemas](technical/web.md#using-a-schema-file-directly): + * Updated for Content Patcher 2.8.0. + * Fixed schema requiring `AddNPCWarps` instead of `AddNpcWarps`. + * Fixed validation error if a warp field contains tokens or consecutive spaces (thanks to irocendar!). + * Fixed validation error if a `Target` contains multiple targets (thanks to irocendar!). + * Fixed `FromFile` errors like "_matches a schema that is not allowed_" (thanks to irocendar!). + ## 4.3.2 Released 14 July 2025 for Stardew Valley 1.6.14 or later. See [4.3 release highlights](https://www.patreon.com/posts/133992196). diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 943d16eb..28b4f77e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,36 +5,35 @@ - + - + - + - - + - - + + - - + + - - - - - + + + + + diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs index 771079fe..c5ec5bc6 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs @@ -15,7 +15,20 @@ internal class ApplySaveFixCommand : ConsoleCommand *********/ /// Construct an instance. public ApplySaveFixCommand() - : base("apply_save_fix", "Apply one of the game's save migrations to the currently loaded save. WARNING: This may corrupt or make permanent changes to your save. DO NOT USE THIS unless you're absolutely sure.\n\nUsage: apply_save_fix list\nList all valid save IDs.\n\nUsage: apply_save_fix \nApply the named save fix.") { } + : base( + name: "apply_save_fix", + description: + """ + Apply one of the game's save migrations to the currently loaded save. WARNING: This may corrupt or make permanent changes to your save. DO NOT USE THIS unless you're absolutely sure. + + Usage: apply_save_fix list + List all valid save IDs. + + Usage: apply_save_fix + Apply the named save fix. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs index 4b4256b2..1a995c3c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs @@ -18,7 +18,19 @@ internal class RegenerateBundlesCommand : ConsoleCommand *********/ /// Construct an instance. public RegenerateBundlesCommand() - : base("regenerate_bundles", $"Regenerate the game's community center bundle data. WARNING: this will reset all bundle progress, and may have unintended effects if you've already completed bundles. DO NOT USE THIS unless you're absolutely sure.\n\nUsage: regenerate_bundles confirm [] [ignore_seed]\nRegenerate all bundles for this save. If the is set to '{string.Join("' or '", Enum.GetNames(typeof(Game1.BundleType)))}', change the bundle type for the save. If an 'ignore_seed' option is included, remixed bundles are re-randomized without using the predetermined save seed.\n\nExample: regenerate_bundles remixed confirm") { } + : base( + name: "regenerate_bundles", + description: + $""" + Regenerate the game's community center bundle data. WARNING: this will reset all bundle progress, and may have unintended effects if you've already completed bundles. DO NOT USE THIS unless you're absolutely sure. + + Usage: regenerate_bundles confirm [] [ignore_seed] + Regenerate all bundles for this save. If the is set to '{string.Join("' or '", Enum.GetNames(typeof(Game1.BundleType)))}', change the bundle type for the save. If an 'ignore_seed' option is included, remixed bundles are re-randomized without using the predetermined save seed. + + Example: regenerate_bundles remixed confirm + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/SetVerboseCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/SetVerboseCommand.cs new file mode 100644 index 00000000..91b8b4cf --- /dev/null +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/SetVerboseCommand.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework; +using StardewValley.Extensions; + +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; + +/// A command which toggles verbose mode in the SMAPI log. +internal class SetVerboseCommand : ConsoleCommand +{ + /********* + ** Fields + *********/ + /// The mod register with which to validate mod IDs. + private readonly IModRegistry ModRegistry; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod register with which to validate mod IDs. + public SetVerboseCommand(IModRegistry modRegistry) + : base( + name: "set_verbose", + description: + """ + Toggles whether more detailed information is written to the SMAPI log file (and console in developer mode). This may impact performance. This doesn't affect mods manually set to verbose in the config file. + + Usage: set_verbose + Toggles verbose logging for SMAPI and all mods. + + Usage: set_verbose [true|false] + Sets whether verbose logging is enabled (true) or disabled (false) for SMAPI and all mods. + + Usage: set_verbose [true|false] [modId]+ + Sets whether verbose logging is enabled (true) or disabled (false) for the specified mod IDs. You can specify 'SMAPI' to set it for SMAPI itself. + """, + mayNeedUpdate: true + ) + { + this.ModRegistry = modRegistry; + } + + /// 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) + { + // parse mode + bool setTo = !(Monitor.ForceVerboseLoggingForAll || Monitor.ForceVerboseLogging.Count > 0); + if (args.Count > 0) + { + if (int.TryParse(args[0], out int numeric) && numeric is 0 or 1) + setTo = numeric is 1; + else if (!bool.TryParse(args[0], out setTo)) + { + monitor.Log("Invalid argument: if specified, the first argument should be 'true' or 'false' to indicate whether to enable or disable verbose logging."); + return; + } + } + + // apply + if (args.Count is 0 or 1) + { + if (setTo) + { + Monitor.ForceVerboseLoggingForAll = true; + Monitor.ForceVerboseLogging.Clear(); + monitor.Log("Enabled verbose logs for SMAPI and all mods.", LogLevel.Info); + } + else + { + Monitor.ForceVerboseLoggingForAll = false; + Monitor.ForceVerboseLogging.Clear(); + monitor.Log("Reset to normal.", LogLevel.Info); + } + } + else + { + List toggled = []; + List unknown = []; + + for (int i = 1; i < args.Count; i++) + { + // get mod + string modId = args[i]; + IModInfo? mod; + if (modId.EqualsIgnoreCase("SMAPI")) + { + modId = "SMAPI"; + mod = null; + } + else + { + mod = this.ModRegistry.Get(modId); + if (mod is null) + { + unknown.Add(modId); + continue; + } + + modId = mod.Manifest.UniqueID; + } + + // toggle + Monitor.ForceVerboseLogging.Toggle(modId, setTo); + toggled.Add(mod is null ? "SMAPI" : mod.Manifest.Name); + } + + if (toggled.Count > 0) + monitor.Log($"{(setTo ? "Enabled" : "Disabled")} verbose logging for mod{(toggled.Count > 1 ? "s" : "")} '{string.Join("', '", toggled.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}'.", LogLevel.Info); + + if (unknown.Count > 0) + { + bool plural = unknown.Count > 1; + monitor.Log($"No mod{(plural ? "s" : "")} found with ID{(plural ? "s" : "")} '{string.Join("', '", unknown)}'.", LogLevel.Warn); + } + } + } +} diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index ae2c1dfe..f46b3e0e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -20,7 +20,28 @@ internal class AddCommand : ConsoleCommand *********/ /// Construct an instance. public AddCommand() - : base("player_add", AddCommand.GetDescription()) { } + : base( + name: "player_add", + description: + $""" + Gives the player an item. + + Usage: player_add [count] [quality] + - item id: the item ID (use the 'list_items' command to see a list). + - count (optional): how many of the item to give. + - quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium). + + Usage: player_add name "" [count] [quality] + - 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. + - count (optional): how many of the item to give. + - quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium). + + These examples both add the galaxy sword to your inventory: + player_add weapon 4 + player_add name "Galaxy Sword" + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. @@ -162,27 +183,4 @@ internal class AddCommand : ConsoleCommand 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() - { - return - $""" - Gives the player an item. - - Usage: player_add [count] [quality] - - item id: the item ID (use the 'list_items' command to see a list). - - count (optional): how many of the item to give. - - quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium). - - Usage: player_add name "" [count] [quality] - - 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. - - count (optional): how many of the item to give. - - quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium). - - These examples both add the galaxy sword to your inventory: - player_add weapon 4 - player_add name "Galaxy Sword" - """; - } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index f664c586..db11f16b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -21,7 +21,17 @@ internal class ListItemsCommand : ConsoleCommand *********/ /// Construct an instance. public ListItemsCommand() - : base("list_items", "Lists and searches items in the game data.\n\nUsage: list_items [search]\n- search (optional): an arbitrary search string to filter by.") { } + : base( + name: "list_items", + description: + """ + Lists and searches items in the game data. + + Usage: list_items [search] + - search (optional): an arbitrary search string to filter by. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index 26cde8fa..b928507e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs @@ -13,7 +13,18 @@ internal class SetColorCommand : ConsoleCommand *********/ /// Construct an instance. public SetColorCommand() - : base("player_changecolor", "Sets the color of a player feature.\n\nUsage: player_changecolor \n- target: what to change (one of 'hair', 'eyes', or 'pants').\n- color: a color value in RGB format, like (255,255,255).") { } + : base( + name: "player_changecolor", + description: + """ + Sets the color of a player feature. + + Usage: player_changecolor + - target: what to change (one of 'hair', 'eyes', or 'pants'). + - color: a color value in RGB format, like (255,255,255). + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs index b40aeff2..b4497ec7 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs @@ -19,7 +19,17 @@ internal class SetFarmTypeCommand : ConsoleCommand *********/ /// Construct an instance. public SetFarmTypeCommand() - : base("set_farm_type", "Sets the current player's farm type.\n\nUsage: set_farm_type \n- farm type: the farm type to set. Enter `set_farm_type list` for a list of available farm types.") { } + : base( + name: "set_farm_type", + description: + """ + Sets the current player's farm type. + + Usage: set_farm_type + - farm type: the farm type to set. Enter `set_farm_type list` for a list of available farm types. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs index 9d951229..65b8ff55 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs @@ -13,7 +13,17 @@ internal class SetHealthCommand : ConsoleCommand *********/ /// Construct an instance. public SetHealthCommand() - : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount.") { } + : base( + name: "player_sethealth", + description: + """ + Sets the player's health. + + Usage: player_sethealth [value] + - value: an integer amount. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs index c35255ae..0fcfaaf2 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs @@ -13,7 +13,17 @@ internal class SetMaxHealthCommand : ConsoleCommand *********/ /// Construct an instance. public SetMaxHealthCommand() - : base("player_setmaxhealth", "Sets the player's max health.\n\nUsage: player_setmaxhealth [value]\n- value: an integer amount.") { } + : base( + name: "player_setmaxhealth", + description: + """ + Sets the player's max health. + + Usage: player_setmaxhealth [value] + - value: an integer amount. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs index 9fb73283..2bb76dce 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -13,7 +13,17 @@ internal class SetMaxStaminaCommand : ConsoleCommand *********/ /// Construct an instance. public SetMaxStaminaCommand() - : base("player_setmaxstamina", "Sets the player's max stamina.\n\nUsage: player_setmaxstamina [value]\n- value: an integer amount.") { } + : base( + name: "player_setmaxstamina", + description: + """ + Sets the player's max stamina. + + Usage: player_setmaxstamina [value] + - value: an integer amount. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index fae4edbf..2ee28eb3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -13,7 +13,17 @@ internal class SetMoneyCommand : ConsoleCommand *********/ /// Construct an instance. public SetMoneyCommand() - : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney \n- value: an integer amount.") { } + : base( + name: "player_setmoney", + description: + """ + Sets the player's money. + + Usage: player_setmoney + - value: an integer amount. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs index 6be5aa9b..49ddb3ed 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs @@ -12,7 +12,18 @@ internal class SetNameCommand : ConsoleCommand *********/ /// Construct an instance. public SetNameCommand() - : base("player_setname", "Sets the player's name.\n\nUsage: player_setname \n- target: what to rename (one of 'player' or 'farm').\n- name: the new name to set.") { } + : base( + name: "player_setname", + description: + """ + Sets the player's name. + + Usage: player_setname + - target: what to rename (one of 'player' or 'farm'). + - name: the new name to set. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs index 84d95097..2edd683d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs @@ -13,7 +13,17 @@ internal class SetStaminaCommand : ConsoleCommand *********/ /// Construct an instance. public SetStaminaCommand() - : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount.") { } + : base( + name: "player_setstamina", + description: + """ + Sets the player's stamina. + + Usage: player_setstamina [value] + - value: an integer amount. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs index 9e20ec2d..27c43610 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs @@ -12,7 +12,18 @@ 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 style ID.") { } + : base( + name: "player_changestyle", + description: + """ + Sets the style of a player feature. + + Usage: player_changestyle + - target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender'). + - value: the style ID. + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs index 4fe2e383..25d7828b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs @@ -21,7 +21,18 @@ internal class FreezeTimeCommand : ConsoleCommand *********/ /// Construct an instance. public FreezeTimeCommand() - : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", mayNeedUpdate: true) { } + : base( + name: "world_freezetime", + description: + """ + Freezes or resumes time. + + Usage: world_freezetime [value] + - value: one of 0 (resume), 1 (freeze), or blank (toggle). + """, + mayNeedUpdate: true + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs index af0dffca..000e28c0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs @@ -14,7 +14,17 @@ internal class SetDayCommand : ConsoleCommand *********/ /// Construct an instance. public SetDayCommand() - : base("world_setday", "Sets the day to the specified value.\n\nUsage: world_setday .\n- value: the target day (a number from 1 to 28).") { } + : base( + name: "world_setday", + description: + """ + Sets the day to the specified value. + + Usage: world_setday . + - value: the target day (a number from 1 to 28). + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs index 1d996673..ada5c155 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs @@ -13,7 +13,17 @@ internal class SetMineLevelCommand : ConsoleCommand *********/ /// Construct an instance. public SetMineLevelCommand() - : base("world_setminelevel", "Sets the mine level?\n\nUsage: world_setminelevel \n- value: The target level (a number starting at 1).") { } + : base( + name: "world_setminelevel", + description: + """ + Sets the mine level? + + Usage: world_setminelevel + - value: The target level (a number starting at 1). + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs index 9f87270d..6f09bf05 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs @@ -21,7 +21,17 @@ internal class SetSeasonCommand : ConsoleCommand *********/ /// Construct an instance. public SetSeasonCommand() - : base("world_setseason", "Sets the season to the specified value.\n\nUsage: world_setseason \n- season: the target season (one of 'spring', 'summer', 'fall', 'winter').") { } + : base( + name: "world_setseason", + description: + """ + Sets the season to the specified value. + + Usage: world_setseason + - season: the target season (one of 'spring', 'summer', 'fall', 'winter'). + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs index 9d636620..f53fa8b4 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs @@ -14,7 +14,17 @@ internal class SetTimeCommand : ConsoleCommand *********/ /// Construct an instance. public SetTimeCommand() - : base("world_settime", "Sets the time to the specified value.\n\nUsage: world_settime \n- value: the target time in military time (like 0600 for 6am and 1800 for 6pm).") { } + : base( + name: "world_settime", + description: + """ + Sets the time to the specified value. + + Usage: world_settime + - value: the target time in military time (like 0600 for 6am and 1800 for 6pm). + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs index 544e39de..f65c1ec7 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs @@ -14,7 +14,17 @@ internal class SetYearCommand : ConsoleCommand *********/ /// Construct an instance. public SetYearCommand() - : base("world_setyear", "Sets the year to the specified value.\n\nUsage: world_setyear \n- year: the target year (a number starting from 1).") { } + : base( + name: "world_setyear", + description: + """ + Sets the year to the specified value. + + Usage: world_setyear + - year: the target year (a number starting from 1). + """ + ) + { } /// Handle the command. /// Writes messages to the console and log file. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 64df125b..1a288ec8 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -171,6 +171,8 @@ internal class ItemRepository string id = item.ItemId; // by category + bool createdJuice = false; + bool createdPickle = false; switch (item.Category) { // fish @@ -190,12 +192,23 @@ internal class ItemRepository // greens case SObject.GreensCategory: yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); + createdPickle = true; + + if (item.Edibility > 0 && !item.HasContextTag("edible_mushroom")) + { + yield return this.TryCreate(itemType.Identifier, $"350/{id}", _ => objectDataDefinition.CreateFlavoredJuice(item)); + createdJuice = true; + } + break; // vegetable products case SObject.VegetableCategory: yield return this.TryCreate(itemType.Identifier, $"350/{id}", _ => objectDataDefinition.CreateFlavoredJuice(item)); + createdJuice = true; + yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); + createdPickle = true; break; // flower honey @@ -216,8 +229,12 @@ internal class ItemRepository yield return this.TryCreate(itemType.Identifier, $"447/{item.ItemId}", _ => objectDataDefinition.CreateFlavoredAgedRoe(roeObj)); } + // juice + if (!createdJuice && item.HasContextTag("keg_juice")) + yield return this.TryCreate(itemType.Identifier, $"350/{id}", _ => objectDataDefinition.CreateFlavoredJuice(item)); + // pickles - if (item.HasContextTag("preserves_pickle") && item.Category is not (SObject.GreensCategory or SObject.VegetableCategory)) + if (!createdPickle && item.HasContextTag("preserves_pickle") && item.Category is not (SObject.GreensCategory or SObject.VegetableCategory)) yield return this.TryCreate(itemType.Identifier, $"342/{id}", _ => objectDataDefinition.CreateFlavoredPickle(item)); // dried mushrooms diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs index 82f83273..29e0bb3a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; +using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other; namespace StardewModdingAPI.Mods.ConsoleCommands; @@ -63,10 +64,15 @@ public class ModEntry : Mod /// Find all commands in the assembly. private IEnumerable ScanForCommands() { - return ( - from type in this.GetType().Assembly.GetTypes() - where !type.IsAbstract && typeof(IConsoleCommand).IsAssignableFrom(type) - select (IConsoleCommand)Activator.CreateInstance(type)! - ); + foreach (Type type in this.GetType().Assembly.GetTypes()) + { + if (type.IsAbstract || !typeof(IConsoleCommand).IsAssignableFrom(type)) + continue; + + if (type == typeof(SetVerboseCommand)) + yield return new SetVerboseCommand(this.Helper.ModRegistry); + else + yield return (IConsoleCommand)Activator.CreateInstance(type)!; + } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 295c75eb..3f44f80c 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": "4.3.2", + "Version": "4.4.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueId": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "4.3.2" + "MinimumApiVersion": "4.4.0" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index b915aa08..d3d054e6 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": "4.3.2", + "Version": "4.4.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueId": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "4.3.2" + "MinimumApiVersion": "4.4.0" } diff --git a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj index b4b1a14e..722a0203 100644 --- a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj +++ b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj @@ -1,6 +1,6 @@  - net6.0 + net10.0 diff --git a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj index b4b1a14e..722a0203 100644 --- a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj +++ b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj @@ -1,6 +1,6 @@  - net6.0 + net10.0 diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 9ca9f41b..c3dc17de 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -1,6 +1,6 @@  - net6.0 + net10.0 diff --git a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/CompatibilityRepoClient.cs b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/CompatibilityRepoClient.cs index 19459c5e..4883a0ea 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/CompatibilityRepoClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/CompatibilityRepoClient.cs @@ -57,7 +57,7 @@ public class CompatibilityRepoClient : IDisposable /// Fetch mods from the compatibility list by reading a local copy of the compatibility list repo. /// The full path to the compatibility list repo folder. - public async Task FetchModsFromLocalGitFolderAsync(string gitRepoPath) + public Task FetchModsFromLocalGitFolderAsync(string gitRepoPath) { string modsJsonPath = Path.Combine(gitRepoPath, "data", "mods.jsonc"); string contentPacksJsonPath = Path.Combine(gitRepoPath, "data", "broken-content-packs.jsonc"); @@ -67,11 +67,12 @@ public class CompatibilityRepoClient : IDisposable RawCompatibilityList? mods = JsonConvert.DeserializeObject(File.ReadAllText(modsJsonPath)); RawCompatibilityList? brokenContentPacks = JsonConvert.DeserializeObject(File.ReadAllText(contentPacksJsonPath)); - return + return Task.FromResult( (mods?.Mods ?? []) - .Concat(brokenContentPacks?.BrokenContentPacks ?? []) - .Select(this.ParseRawModEntry) - .ToArray(); + .Concat(brokenContentPacks?.BrokenContentPacks ?? []) + .Select(this.ParseRawModEntry) + .ToArray() + ); } /// Get the inline HTML produced by a Markdown string in a compatibility repo field. @@ -99,7 +100,9 @@ public class CompatibilityRepoClient : IDisposable string[] modIds = this.GetCsv(rawModEntry.Id); string[] modNames = this.GetCsv(rawModEntry.Name); string[] authorNames = this.GetCsv(rawModEntry.Author); + string[] warnings = (rawModEntry.Warnings?.Where(warning => !string.IsNullOrWhiteSpace(warning)).ToArray() ?? [])!; ModCompatibilityStatus status = rawModEntry.GetStatus(); + ModCompatibilityReasonAbandoned reasonAbandoned = rawModEntry.GetReasonAbandoned(); rawModEntry.GetCompatibilitySummary(out string summary, out bool hasMarkdown); // get HTML summary @@ -111,6 +114,26 @@ public class CompatibilityRepoClient : IDisposable htmlSummary = null; } + // get HTML warnings + string[]? htmlWarnings = null; + if (warnings.Length > 0) + { + bool anyDifferent = false; + + htmlWarnings = warnings + .Select(warning => + { + string html = this.ParseMarkdownToInlineHtml(warning); + if (html != warning) + anyDifferent = true; + return html; + }) + .ToArray(); + + if (!anyDifferent) + htmlWarnings = null; + } + // build model return new ModCompatibilityEntry( id: modIds, @@ -130,9 +153,11 @@ public class CompatibilityRepoClient : IDisposable htmlSummary: htmlSummary, brokeIn: rawModEntry.BrokeIn, unofficialVersion: this.GetSemanticVersion(rawModEntry.UnofficialUpdate?.Version), - unofficialUrl: rawModEntry.UnofficialUpdate?.Url + unofficialUrl: rawModEntry.UnofficialUpdate?.Url, + abandonedReason: reasonAbandoned ), - warnings: rawModEntry.Warnings ?? [], + warnings: warnings, + htmlWarnings: htmlWarnings, devNote: rawModEntry.DeveloperNotes, overrides: this.ParseOverrideEntries(modIds, rawModEntry.OverrideModData), anchor: PathUtilities.CreateSlug(modNames.FirstOrDefault())?.ToLower() diff --git a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityEntry.cs index a53acaa8..848d9202 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityEntry.cs @@ -45,9 +45,12 @@ public class ModCompatibilityEntry /// The mod's compatibility with the latest stable version of the game. public ModCompatibilityInfo Compatibility { get; } - /// The human-readable warnings for players about this mod. + /// The human-readable warnings for players about this mod, in Markdown format. public string[] Warnings { get; } + /// The HTML version of , if different. + public string[]? HtmlWarnings { get; } + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. public string? DevNote { get; } @@ -62,23 +65,24 @@ public class ModCompatibilityEntry ** Public methods *********/ /// Construct an instance. - /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. - /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. - /// The mod's author name. If the author has multiple names, the first one is the most canonical name. - /// The mod ID on Nexus. - /// The mod ID in the Chucklefish mod repo. - /// The mod ID in the CurseForge mod repo. - /// The mod ID in the ModDrop mod repo. - /// The GitHub repository in the form 'owner/repo'. - /// The URL to a non-GitHub source repo. - /// The custom mod page URL (if applicable). - /// The name of the mod which loads this content pack, if applicable. - /// The mod's compatibility with the latest stable version of the game. - /// The human-readable warnings for players about this mod. - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - /// The data overrides to apply to the mod's manifest or remote mod page data, if any. - /// The link anchor for the mod entry in the compatibility list. - public ModCompatibilityEntry(string[] id, string[] name, string[] author, int? nexusId, int? chucklefishId, int? curseForgeId, int? modDropId, string? githubRepo, string? customSourceUrl, string? customUrl, string? contentPackFor, ModCompatibilityInfo compatibility, string[] warnings, string? devNote, ModDataOverrideEntry? overrides, string? anchor) + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ModCompatibilityEntry(string[] id, string[] name, string[] author, int? nexusId, int? chucklefishId, int? curseForgeId, int? modDropId, string? githubRepo, string? customSourceUrl, string? customUrl, string? contentPackFor, ModCompatibilityInfo compatibility, string[] warnings, string[]? htmlWarnings, string? devNote, ModDataOverrideEntry? overrides, string? anchor) { this.ID = id; this.Name = name; @@ -93,6 +97,7 @@ public class ModCompatibilityEntry this.ContentPackFor = contentPackFor; this.Compatibility = compatibility; this.Warnings = warnings; + this.HtmlWarnings = htmlWarnings; this.DevNote = devNote; this.Overrides = overrides; this.Anchor = anchor; @@ -136,4 +141,19 @@ public class ModCompatibilityEntry if (!anyFound && !string.IsNullOrWhiteSpace(this.GitHubRepo)) yield return new KeyValuePair(ModSiteKey.GitHub, $"https://github.com/{this.GitHubRepo}/releases"); } + + /// Get whether this compatibility entry refers to the given mod site page ID. + /// The mod site. + /// The mod page ID. + public bool HasSiteId(ModSiteKey site, int id) + { + return site switch + { + ModSiteKey.Chucklefish => this.ChucklefishID == id, + ModSiteKey.CurseForge => this.CurseForgeID == id, + ModSiteKey.ModDrop => this.ModDropID == id, + ModSiteKey.Nexus => this.NexusID == id, + _ => false + }; + } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityInfo.cs b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityInfo.cs index 9d33bd54..446b6833 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityInfo.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityInfo.cs @@ -24,18 +24,22 @@ public class ModCompatibilityInfo /// The URL to the latest unofficial update, if applicable. public string? UnofficialUrl { get; } + /// If the compatibility status is , the reason it was abandoned. + public ModCompatibilityReasonAbandoned AbandonedReason { get; } + /********* ** Accessors *********/ /// Construct an instance. - /// The compatibility status. - /// The human-readable summary of the compatibility status or workaround, in Markdown format. - /// An HTML version of , if different. - /// The game or SMAPI version which broke this mod, if applicable. - /// The version of the latest unofficial update, if applicable. - /// The URL to the latest unofficial update, if applicable. - public ModCompatibilityInfo(ModCompatibilityStatus status, string? summary, string? htmlSummary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl) + /// + /// + /// + /// + /// + /// + /// + public ModCompatibilityInfo(ModCompatibilityStatus status, string? summary, string? htmlSummary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl, ModCompatibilityReasonAbandoned abandonedReason) { this.Status = status; this.Summary = summary; @@ -43,6 +47,7 @@ public class ModCompatibilityInfo this.BrokeIn = brokeIn; this.UnofficialVersion = unofficialVersion; this.UnofficialUrl = unofficialUrl; + this.AbandonedReason = abandonedReason; } /// Get whether this mod is compatible with no notes. diff --git a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityReasonAbandoned.cs b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityReasonAbandoned.cs new file mode 100644 index 00000000..f10d8d8c --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/ModCompatibilityReasonAbandoned.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.CompatibilityRepo; + +/// The reason a mod is marked abandoned. +public enum ModCompatibilityReasonAbandoned +{ + /// Not applicable. + None, + + /// The mod has been explicitly and definitively abandoned by the author. + Author, + + /// The mod pages or files have been deleted, so the mod is no longer available. + Deleted, + + /// The mod pages have been hidden, so the mod is no longer available. + Hidden +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/RawDataModels/RawModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/RawDataModels/RawModEntry.cs index ec8d19ab..98a32e17 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/RawDataModels/RawModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/CompatibilityRepo/RawDataModels/RawModEntry.cs @@ -45,7 +45,7 @@ public class RawModEntry public string? Source { get; set; } /// Custom text indicating compatibility issues with the mod (e.g. not compatible with Linux/Mac). - public string[]? Warnings { get; set; } + public string?[]? Warnings { get; set; } /// Special notes intended for developers who maintain unofficial updates or submit pull requests. public string? DeveloperNotes { get; set; } @@ -65,6 +65,9 @@ public class RawModEntry /// The unofficial update which fixes compatibility with the latest Stardew Valley and SMAPI versions. public RawModUnofficialUpdate? UnofficialUpdate { get; set; } + /// If the compatibility status is , the reason it was abandoned. + public string? AbandonedReason { get; set; } + /**** ** Content packs only ****/ @@ -105,6 +108,14 @@ public class RawModEntry return status; } + /// Get the reason the mod was abandoned, if applicable. + public ModCompatibilityReasonAbandoned GetReasonAbandoned() + { + return Enum.TryParse(this.AbandonedReason, true, out ModCompatibilityReasonAbandoned reason) + ? reason + : ModCompatibilityReasonAbandoned.None; + } + /// Get the compatibility summary as shown on the compatibility list. /// The compatibility summary. /// Whether the summary may contain Markdown formatting. diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index ae033333..ff28a3dc 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -62,7 +62,17 @@ public class GameScanner // yield valid folders foreach (string path in paths) { - DirectoryInfo folder = new(path); + DirectoryInfo folder; + + try + { + folder = new DirectoryInfo(path); + } + catch (ArgumentException) + { + continue; // invalid path format + } + if (folder.Exists) yield return (folder, this.GetGameFolderType(folder)); } @@ -159,7 +169,7 @@ public class GameScanner // via Steam library path string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); - if (steamPath != null) + if (!string.IsNullOrWhiteSpace(steamPath)) { // conventional path yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs index 9afbf6f6..dc920550 100644 --- a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -1,10 +1,8 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Reflection; #if SMAPI_FOR_WINDOWS -using System.Management; #endif using System.Runtime.InteropServices; using StardewModdingAPI.Toolkit.Utilities; @@ -54,24 +52,8 @@ internal static class LowLevelEnvironmentUtility /// The current platform. public static string GetFriendlyPlatformName(string platform) { -#if SMAPI_FOR_WINDOWS - try - { - string? result = new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") - .Get() - .Cast() - .Select(entry => entry.GetPropertyValue("Caption").ToString()) - .FirstOrDefault(); - - return result ?? "Windows"; - } - catch - { - // fallback to default behavior - } -#endif - string name = Environment.OSVersion.ToString(); + switch (platform) { case nameof(Platform.Android): @@ -81,7 +63,17 @@ internal static class LowLevelEnvironmentUtility case nameof(Platform.Mac): name = $"macOS {name}"; break; + + case nameof(Platform.Windows): + // version 10 + 11 + if (Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version is { Major: 10, Minor: 0 } version) + { + int mainVersion = version.Build switch { < 22000 => 10, _ => 11 }; + name = $"Windows {mainVersion} ({name.Replace("Microsoft Windows NT ", "")})"; + } + break; } + return name; } diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs index f6f9b0e5..451b52f5 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -10,13 +10,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning; /// The info about a mod read from its folder. public class ModFolder { - /********* - ** Fields - *********/ - /// The backing field for . - private DirectoryInfo? DirectoryImpl; - - /********* ** Accessors *********/ @@ -28,7 +21,7 @@ public class ModFolder /// The folder containing the mod's manifest.json. [JsonIgnore] - public DirectoryInfo Directory => this.DirectoryImpl ??= new DirectoryInfo(this.DirectoryPath); + public DirectoryInfo Directory => field ??= new DirectoryInfo(this.DirectoryPath); /// The mod type. public ModType Type { get; } @@ -64,7 +57,7 @@ public class ModFolder public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest? manifest, ModParseError manifestParseError, string? manifestParseErrorText) { // save info - this.DirectoryImpl = directory; + this.Directory = directory; this.DirectoryPath = directory.FullName; this.Type = type; this.Manifest = manifest; diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 34dd3aaf..6766cb25 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -15,7 +15,6 @@ - diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index 4849bbb2..23838254 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -6,7 +6,6 @@ using Hangfire; using Hangfire.Console; using Hangfire.Server; using Humanizer; -using Humanizer.Localisation; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 85c9594d..1f8421fb 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -2,7 +2,7 @@ SMAPI.Web StardewModdingAPI.Web - net8.0 + net10.0 latest diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index ebcfc107..6d8c108c 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -53,18 +53,18 @@ public class ModModel ** Public methods *********/ /// Construct an instance. - /// The mod IDs. - /// The mod name. - /// The mod's alternative names, if any. - /// The mod author's name. - /// The mod author's alternative names, if any. - /// The GitHub repo, if any. - /// The URL to the mod's source code, if any. - /// The compatibility status for the stable version of the game. - /// Links to the available mod pages. - /// The human-readable warnings for players about this mod. - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - /// A unique identifier for the mod that can be used in an anchor URL. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// [JsonConstructor] public ModModel(string[] id, string? name, string? alternateNames, string? author, string? alternateAuthors, string? gitHubRepo, string? sourceUrl, ModCompatibilityModel? compatibility, ModLinkModel[] modPages, string[]? warnings, string? devNote, string? slug) { @@ -96,9 +96,12 @@ public class ModModel this.SourceUrl = this.GetSourceUrl(entry); this.Compatibility = !entry.Compatibility.IsDefault() ? new ModCompatibilityModel(entry.Compatibility) : null; this.ModPages = this.GetModPageUrls(entry).ToArray(); - this.Warnings = entry.Warnings.Length > 1 ? entry.Warnings : null; + this.Warnings = entry.HtmlWarnings ?? entry.Warnings; this.DevNote = entry.DevNote; this.Slug = entry.Anchor; + + if (this.Warnings.Length == 0) + this.Warnings = null; } diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index dd9d6a8c..f3e0e500 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -1,5 +1,4 @@ @using Humanizer -@using Humanizer.Localisation @using StardewModdingAPI.Web.Framework @model StardewModdingAPI.Web.ViewModels.ModListModel @{ @@ -109,7 +108,7 @@ else
-
⚠ {{warning}}
+
⚠
diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index d0f6dafe..bafcb5bd 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": "^2\\.7\\.[0-9]+$", + "pattern": "^2\\.8\\.[0-9]+$", "@errorMessages": { - "pattern": "Incorrect value '@value'. You should always use the latest format version (currently 2.7.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.8.0) to enable the latest features, avoid obsolete behavior, and reduce load times." } }, "ConfigSchema": { @@ -114,7 +114,7 @@ "allOf": [ { "not": { - "pattern": "\\b\\.\\.[/\\]" + "pattern": "\\b\\.\\.[/\\\\]" } }, { @@ -195,7 +195,7 @@ "description": "The game asset you want to patch (or multiple comma-delimited assets). This is the file path inside your game's Content folder, without the file extension or language (like Animals/Dinosaur to edit Content/Animals/Dinosaur.xnb). This field supports tokens and capitalization doesn't matter. Your changes are applied in all languages unless you specify a language condition.", "type": "string", "not": { - "pattern": "^ *[cC][oO][nN][tT][eE][nN][tT]/|\\.[xX][nN][bB] *$|\\.[a-zA-Z][a-zA-Z]-[a-zA-Z][a-zA-Z](?:.xnb)? *$" + "pattern": "(?i)(?:\\bcontent/|\\.xnb\\b|\\.[a-zA-Z][a-zA-Z]-[a-zA-Z][a-zA-Z](?:.xnb)\\b)" }, "@errorMessages": { "not": "Invalid target; it shouldn't include the 'Content/' folder, '.xnb' extension, or locale code." @@ -236,7 +236,7 @@ "allOf": [ { "not": { - "pattern": "\\b\\.\\.[/\\]" + "pattern": "\\b\\.\\.[/\\\\]" } }, { @@ -353,7 +353,7 @@ "type": "array", "items": { "type": "string", - "pattern": " *-?\\d+ -?\\d+ [A-Za-z0-9_\\.]+ -?\\d+ -?\\d+ *$", + "pattern": " *((.*\\{\\{.*\\}\\})|-?\\d+ -?\\d+ [A-Za-z0-9_\\.]+ -?\\d+ -?\\d+) *$", "@errorMessages": { "pattern": "Each warp must match the exact format recognized by the game's Warp map property (i.e. 'fromX fromY targetMap targetX targetY', like '10 10 Town 0 30')." } @@ -365,7 +365,7 @@ "type": "array", "items": { "type": "string", - "pattern": " *-?\\d+ -?\\d+ [A-Za-z0-9_\\.]+ -?\\d+ -?\\d+ *$", + "pattern": " *((.*\\{\\{.*\\}\\})|(-?\\d+ -?\\d+ [A-Za-z0-9_\\.]+ -?\\d+ -?\\d+)) *$", "@errorMessages": { "pattern": "Each warp must match the exact format recognized by the game's NPCWarp map property (i.e. 'fromX fromY targetMap targetX targetY', like '10 10 Town 0 30')." } @@ -572,7 +572,7 @@ "When", "AddWarps", - "AddNPCWarps", + "AddNpcWarps", "FromArea", "MapProperties", "MapTiles", diff --git a/src/SMAPI.sln b/src/SMAPI.sln deleted file mode 100644 index a20c18e2..00000000 --- a/src/SMAPI.sln +++ /dev/null @@ -1,194 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{86C452BE-D2D8-45B4-B63F-E329EB06CEDA}" - ProjectSection(SolutionItems) = preProject - ..\.editorconfig = ..\.editorconfig - ..\.gitattributes = ..\.gitattributes - ..\.gitignore = ..\.gitignore - Directory.Packages.props = Directory.Packages.props - ..\LICENSE.txt = ..\LICENSE.txt - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{4B1CEB70-F756-4A57-AAE8-8CD78C475F25}" - ProjectSection(SolutionItems) = preProject - ..\.github\CONTRIBUTING.md = ..\.github\CONTRIBUTING.md - ..\.github\SUPPORT.md = ..\.github\SUPPORT.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{F4453AB6-D7D6-447F-A973-956CC777968F}" - ProjectSection(SolutionItems) = preProject - ..\.github\ISSUE_TEMPLATE\config.yml = ..\.github\ISSUE_TEMPLATE\config.yml - ..\.github\ISSUE_TEMPLATE\custom.md = ..\.github\ISSUE_TEMPLATE\custom.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5-5BAB-4650-A200-E5EA9A633046}" - ProjectSection(SolutionItems) = preProject - ..\build\common.targets = ..\build\common.targets - ..\build\deploy-local-smapi.targets = ..\build\deploy-local-smapi.targets - ..\build\find-game-folder.targets = ..\build\find-game-folder.targets - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB}" - ProjectSection(SolutionItems) = preProject - ..\docs\mod-build-config.md = ..\docs\mod-build-config.md - ..\docs\README.md = ..\docs\README.md - ..\docs\release-notes-archived.md = ..\docs\release-notes-archived.md - ..\docs\release-notes.md = ..\docs\release-notes.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "technical", "technical", "{5947303D-3512-413A-9009-7AC43F5D3513}" - ProjectSection(SolutionItems) = preProject - ..\docs\technical\mod-package-release-notes.md = ..\docs\technical\mod-package-release-notes.md - ..\docs\technical\mod-package.md = ..\docs\technical\mod-package.md - ..\docs\technical\smapi.md = ..\docs\technical\smapi.md - ..\docs\technical\web.md = ..\docs\technical\web.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Internal", "Internal", "{82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mods", "Mods", "{AE9A4D46-E910-4293-8BC4-673F85FFF6A5}" -EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SMAPI.Internal", "SMAPI.Internal\SMAPI.Internal.shproj", "{85208F8D-6FD1-4531-BE05-7142490F59FE}" -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}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI", "SMAPI\SMAPI.csproj", "{E6DA2198-7686-4F1D-B312-4A4DC70884C0}" -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} - {CD53AD6F-97F4-4872-A212-50C2A0FD3601} = {CD53AD6F-97F4-4872-A212-50C2A0FD3601} - {E6DA2198-7686-4F1D-B312-4A4DC70884C0} = {E6DA2198-7686-4F1D-B312-4A4DC70884C0} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig", "SMAPI.ModBuildConfig\SMAPI.ModBuildConfig.csproj", "{1B3821E6-D030-402C-B3A1-7CA45C2800EA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer", "SMAPI.ModBuildConfig.Analyzer\SMAPI.ModBuildConfig.Analyzer.csproj", "{517677D7-7299-426F-B1A3-47BDCC2F1214}" -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.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}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit.CoreInterfaces", "SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj", "{ED8E41FA-DDFA-4A77-932E-9853D279A129}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "windows", "windows", "{4D661178-38FB-43E4-AA5F-9B0406919344}" - ProjectSection(SolutionItems) = preProject - ..\build\windows\finalize-install-package.sh = ..\build\windows\finalize-install-package.sh - ..\build\windows\prepare-install-package.ps1 = ..\build\windows\prepare-install-package.ps1 - ..\build\windows\set-smapi-version.ps1 = ..\build\windows\set-smapi-version.ps1 - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unix", "unix", "{CAA1488E-842B-433D-994D-1D3D0B5DD125}" - ProjectSection(SolutionItems) = preProject - ..\build\unix\prepare-install-package.sh = ..\build\unix\prepare-install-package.sh - ..\build\unix\set-smapi-version.sh = ..\build\unix\set-smapi-version.sh - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{3B5BF14D-F612-4C83-9EF6-E3EBFCD08766}" - ProjectSection(SolutionItems) = preProject - ..\build\windows\lib\in-place-regex.ps1 = ..\build\windows\lib\in-place-regex.ps1 - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests.ModApiProvider", "SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj", "{239AEEAC-07D1-4A3F-AA99-8C74F5038F50}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests.ModApiConsumer", "SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj", "{2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}" -EndProject -Global - GlobalSection(SharedMSBuildProjectFiles) = preSolution - SMAPI.Internal\SMAPI.Internal.projitems*{0a9bb24f-15ff-4c26-b1a2-81f7ae316518}*SharedItemsImports = 5 - 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\SMAPI.Internal.projitems*{e6da2198-7686-4f1d-b312-4a4dc70884c0}*SharedItemsImports = 5 - EndGlobalSection - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Release|Any CPU.Build.0 = Release|Any CPU - {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Release|Any CPU.Build.0 = Release|Any CPU - {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Release|Any CPU.Build.0 = Release|Any CPU - {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Release|Any CPU.Build.0 = Release|Any CPU - {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Release|Any CPU.Build.0 = Release|Any CPU - {517677D7-7299-426F-B1A3-47BDCC2F1214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {517677D7-7299-426F-B1A3-47BDCC2F1214}.Debug|Any CPU.Build.0 = Debug|Any CPU - {517677D7-7299-426F-B1A3-47BDCC2F1214}.Release|Any CPU.ActiveCfg = Release|Any CPU - {517677D7-7299-426F-B1A3-47BDCC2F1214}.Release|Any CPU.Build.0 = Release|Any CPU - {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - {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 - {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Release|Any CPU.Build.0 = Release|Any CPU - {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Debug|Any CPU.Build.0 = Debug|Any CPU - {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Release|Any CPU.ActiveCfg = Release|Any CPU - {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Release|Any CPU.Build.0 = Release|Any CPU - {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Release|Any CPU.Build.0 = Release|Any CPU - {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU - {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU - {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU - {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.Build.0 = Debug|Any CPU - {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.ActiveCfg = Release|Any CPU - {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.Build.0 = Release|Any CPU - {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {4B1CEB70-F756-4A57-AAE8-8CD78C475F25} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} - {F4453AB6-D7D6-447F-A973-956CC777968F} = {4B1CEB70-F756-4A57-AAE8-8CD78C475F25} - {09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} - {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} - {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} - {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} - {3B5BF14D-F612-4C83-9EF6-E3EBFCD08766} = {4D661178-38FB-43E4-AA5F-9B0406919344} - {239AEEAC-07D1-4A3F-AA99-8C74F5038F50} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} - {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC} - EndGlobalSection -EndGlobal diff --git a/src/SMAPI.slnx b/src/SMAPI.slnx new file mode 100644 index 00000000..2e93538e --- /dev/null +++ b/src/SMAPI.slnx @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.slnx.DotSettings similarity index 100% rename from src/SMAPI.sln.DotSettings rename to src/SMAPI.slnx.DotSettings diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 15977de8..06e066eb 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -49,7 +49,7 @@ internal static class EarlyConstants internal static int? LogScreenId { get; set; } /// SMAPI's current raw semantic version. - internal static string RawApiVersion = "4.3.2"; + internal static string RawApiVersion = "4.4.0"; } /// Contains SMAPI's constants and assumptions. diff --git a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs index 3fcec7f4..f318f335 100644 --- a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs +++ b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs @@ -17,7 +17,16 @@ internal class HarmonySummaryCommand : IInternalCommand public string Name { get; } = "harmony_summary"; /// The human-readable documentation shown when the player runs the built-in 'help' command. - public string Description { get; } = "Harmony is a library which rewrites game code, used by SMAPI and some mods. This command lists current Harmony patches.\n\nUsage: harmony_summary\nList all Harmony patches.\n\nUsage: harmony_summary \n- search: one more more words to search. If any word matches a method name, the method and all its patchers will be listed; otherwise only matching patchers will be listed for the method."; + public string Description { get; } = + """ + Harmony is a library which rewrites game code, used by SMAPI and some mods. This command lists current Harmony patches. + + Usage: harmony_summary + List all Harmony patches. + + Usage: harmony_summary + - search: one more more words to search. If any word matches a method name, the method and all its patchers will be listed; otherwise only matching patchers will be listed for the method. + """; /********* diff --git a/src/SMAPI/Framework/Commands/HelpCommand.cs b/src/SMAPI/Framework/Commands/HelpCommand.cs index e7d1f4af..59bd34b4 100644 --- a/src/SMAPI/Framework/Commands/HelpCommand.cs +++ b/src/SMAPI/Framework/Commands/HelpCommand.cs @@ -19,7 +19,16 @@ internal class HelpCommand : IInternalCommand public string Name { get; } = "help"; /// The human-readable documentation shown when the player runs the built-in 'help' command. - public string Description { get; } = "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help \n- cmd: The name of a command whose documentation to display."; + public string Description { get; } = + """ + Lists command documentation. + + Usage: help + Lists all available commands. + + Usage: help + - cmd: The name of a command whose documentation to display. + """; /********* diff --git a/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs b/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs index cb6824fd..5a8b2866 100644 --- a/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs +++ b/src/SMAPI/Framework/Commands/ReloadI18nCommand.cs @@ -21,7 +21,12 @@ internal class ReloadI18nCommand : IInternalCommand public string Name { get; } = "reload_i18n"; /// The human-readable documentation shown when the player runs the built-in 'help' command. - public string Description { get; } = "Reloads translation files for all mods.\n\nUsage: reload_i18n"; + public string Description { get; } = + """ + Reloads translation files for all mods. + + Usage: reload_i18n + """; /********* diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index f59e73be..e27dc1ca 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -205,30 +205,53 @@ internal class AssetDataForImage : AssetData, IAssetDataForImage for (int i = startIndex; i <= endIndex; i++) { - int targetIndex = i - sourceOffset; - + // get source pixel Color above = sourceData[i]; - Color below = mergedData[targetIndex]; - - // shortcut transparency if (above.A < AssetDataForImage.MinOpacity) continue; - if (below.A < AssetDataForImage.MinOpacity || above.A == byte.MaxValue) - mergedData[targetIndex] = above; - // merge pixels + // get target pixel + int targetIndex = i - sourceOffset; + Color below = mergedData[targetIndex]; + + // apply + if (patchMode == PatchMode.Overlay) + { + // merge pixels + if (below.A < AssetDataForImage.MinOpacity || above.A == byte.MaxValue) + mergedData[targetIndex] = above; + else + { + // This performs a conventional alpha blend for the pixels, which are already + // premultiplied by the content pipeline. The formula is derived from + // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. + float alphaBelow = 1 - (above.A / 255f); + mergedData[targetIndex] = new Color( + r: (int)(above.R + (below.R * alphaBelow)), + g: (int)(above.G + (below.G * alphaBelow)), + b: (int)(above.B + (below.B * alphaBelow)), + alpha: Math.Max(above.A, below.A) + ); + } + } else { - // This performs a conventional alpha blend for the pixels, which are already - // premultiplied by the content pipeline. The formula is derived from - // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. - float alphaBelow = 1 - (above.A / 255f); - mergedData[targetIndex] = new Color( - r: (int)(above.R + (below.R * alphaBelow)), - g: (int)(above.G + (below.G * alphaBelow)), - b: (int)(above.B + (below.B * alphaBelow)), - alpha: Math.Max(above.A, below.A) - ); + // subtract mask alpha + int newAlpha = below.A - above.A; + if (newAlpha <= 0) + mergedData[targetIndex] = Color.Transparent; + else + { + // Since the pixels are already premultiplied by the pipeline based on the + // alpha, rescale the RGB channels too to match the new alpha. + float scale = (float)newAlpha / below.A; + mergedData[targetIndex] = new Color( + r: (int)Math.Clamp(Math.Round(below.R * scale), 0, 255), + g: (int)Math.Clamp(Math.Round(below.G * scale), 0, 255), + b: (int)Math.Clamp(Math.Round(below.B * scale), 0, 255), + alpha: newAlpha + ); + } } } diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index ec067b8f..324e7afd 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -12,9 +12,6 @@ internal class AssetInfo : IAssetInfo /// Normalizes an asset key to match the cache key. protected readonly Func GetNormalizedPath; - /// The backing field for . - private IAssetName? NameWithoutLocaleImpl; - /********* ** Accessors @@ -26,7 +23,7 @@ internal class AssetInfo : IAssetInfo public IAssetName Name { get; } /// - public IAssetName NameWithoutLocale => this.NameWithoutLocaleImpl ??= this.Name.GetBaseAssetName(); + public IAssetName NameWithoutLocale => field ??= this.Name.GetBaseAssetName(); /// public Type DataType { get; } diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 0780a431..15ac93f2 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -452,7 +452,7 @@ internal class ContentCoordinator : IDisposable contentManagers: this.ContentManagers, assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, - out IDictionary propagated, + out Dictionary propagated, out bool updatedWarpRoutes ); diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index f11ce964..503fd984 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,5 +1,4 @@ using System; -using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -30,6 +29,9 @@ internal sealed class ModContentManager : BaseContentManager /********* ** Fields *********/ + /// A path segment which navigates to the parent directory. + private const string DirectoryClimbingPathSegment = ".."; + /// Encapsulates SMAPI's JSON file parsing. private readonly JsonHelper JsonHelper; @@ -352,38 +354,6 @@ internal sealed class ModContentManager : BaseContentManager return file; } - /// Premultiply a texture's alpha values to avoid transparency issues in the game. - /// The texture to premultiply. - /// Returns a premultiplied texture. - /// Based on code by David Gouveia. - private void PremultiplyTransparency(Texture2D texture) - { - int count = texture.Width * texture.Height; - Color[] data = ArrayPool.Shared.Rent(count); - try - { - texture.GetData(data, 0, count); - - bool changed = false; - for (int i = 0; i < count; i++) - { - ref Color pixel = ref data[i]; - if (pixel.A is (byte.MinValue or byte.MaxValue)) - continue; // no need to change fully transparent/opaque pixels - - data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) - changed = true; - } - - if (changed) - texture.SetData(data, 0, count); - } - finally - { - ArrayPool.Shared.Return(data); - } - } - /// Fix custom map tilesheet paths so they can be found by the content manager. /// The map whose tilesheets to fix. /// The relative map path within the mod folder. @@ -399,28 +369,41 @@ internal sealed class ModContentManager : BaseContentManager this.Monitor.VerboseLog($"Fixing tilesheet paths for map '{relativeMapPath}' from mod '{this.ModName}'..."); foreach (TileSheet tilesheet in map.TileSheets) { - // get image source + // get image path tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource); string imageSource = tilesheet.ImageSource; + if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource?.StartsWith(relativeMapFolder) is true) + imageSource = imageSource[(relativeMapFolder.Length + 1)..]; - // validate image source + // ensure path isn't empty if (string.IsNullOrWhiteSpace(imageSource)) throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet '{tilesheet.Id}'. This tilesheet has no image source."); - // reverse incorrect eager tilesheet path prefixing - if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder)) - imageSource = imageSource[(relativeMapFolder.Length + 1)..]; + // ensure path is relative + if (Path.IsPathRooted(imageSource)) + throw this.GetPathError(relativeMapFolder, imageSource, $"Tilesheet paths must not be an absolute path ({Path.GetPathRoot(imageSource)})."); - // validate tilesheet path - string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; - if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); + // ensure any directory climbing is valid + // Tilesheet paths are relative to either the `Content/Maps` folder or the map file. Directory climbing is + // restricted for safety and simplicity: + // 1. Can only climb at the start of the path (e.g. `../LooseSprites/Cursors` but not `Mines/../Barn`). + // 2. Can only climb once (to avoid escaping the `Content` folder). + // 3. Always relative to the `Content/Maps` folder (not the map file). + { + const string climbSegment = ModContentManager.DirectoryClimbingPathSegment; + string[] pathSegments = PathUtilities.GetSegments(imageSource); + if (pathSegments.Contains(climbSegment)) + { + if (pathSegments[0] != climbSegment || pathSegments.Count(segment => segment == climbSegment) > 1) + throw this.GetPathError(relativeMapFolder, imageSource, $"Directory climbing ({climbSegment}/) is only permitted once at the start of the path."); + } + } // load best match try { if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error)) - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} {error}"); + throw this.GetPathError(relativeMapFolder, imageSource, error); if (assetName is not null) { @@ -435,7 +418,7 @@ internal sealed class ModContentManager : BaseContentManager if (ex is SContentLoadException) throw; - throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} The tilesheet couldn't be loaded.", ex); + throw this.GetPathError(relativeMapFolder, imageSource, "The tilesheet couldn't be loaded.", ex); } } } @@ -444,10 +427,10 @@ internal sealed class ModContentManager : BaseContentManager /// The folder path containing the map, relative to the mod folder. /// The tilesheet path to load. /// The found asset name. - /// A message indicating why the file couldn't be loaded. + /// A message indicating why the file couldn't be loaded, if applicable. /// Returns whether the asset name was found. /// See remarks on . - private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error) + private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, [NotNullWhen(false)] out string? error) { error = null; @@ -467,7 +450,9 @@ internal sealed class ModContentManager : BaseContentManager relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.')); } - // get relative to map file + // get relative to map file unless path has directory climbing + const string climbSegment = ModContentManager.DirectoryClimbingPathSegment; + if (!relativePath.StartsWith(climbSegment) && PathUtilities.GetSegments(relativePath, 2)[0] != climbSegment) // directory climbing can only be at the start of the path { string localKey = Path.Combine(modRelativeMapFolder, relativePath); if (this.GetModFile(localKey).Exists) @@ -522,11 +507,17 @@ internal sealed class ModContentManager : BaseContentManager private string GetContentKeyForTilesheetImageSource(string relativePath) { string key = relativePath; - string topFolder = PathUtilities.GetSegments(key, limit: 2)[0]; - // convert image source relative to map file into asset key - if (!topFolder.Equals("Maps", StringComparison.OrdinalIgnoreCase)) - key = Path.Combine("Maps", key); + // make path relative to Content folder + { + string topFolder = PathUtilities.GetSegments(key, limit: 2)[0]; + const string climbSegment = ModContentManager.DirectoryClimbingPathSegment; + + if (topFolder == climbSegment) + key = key[(climbSegment.Length + 1)..]; + else if (!topFolder.Equals("Maps", StringComparison.OrdinalIgnoreCase)) + key = Path.Combine("Maps", key); + } // remove file extension from unpacked file if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) @@ -534,4 +525,14 @@ internal sealed class ModContentManager : BaseContentManager return key; } + + /// Get an exception indicating that a map asset can't be loaded because one of its tilesheets has an invalid path. + /// The relative path to the folder which contains the map asset, relative to the mod folder. + /// The tilesheet's path to the image file. + /// The error message indicating why loading it failed. (Basic metadata like the image source is prepended automatically.) + /// The inner exception which caused the error, if applicable. + private SContentLoadException GetPathError(string relativeMapPath, string imageSource, string error, Exception? ex = null) + { + return new SContentLoadException(ContentLoadErrorType.InvalidData, $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'. {error}", ex); + } } diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index 66b93f52..5d93fc10 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -58,7 +58,7 @@ internal class LogManager : IDisposable this.LogFile = new LogFileManager(logPath); // init monitor - this.GetMonitorImpl = (id, name) => new Monitor(name, this.LogFile, colorConfig, verboseLogging.Contains("*") || verboseLogging.Contains(id), getScreenIdForLog) + this.GetMonitorImpl = (id, name) => new Monitor(id, name, this.LogFile, colorConfig, verboseLogging.Contains("*") || verboseLogging.Contains(id), getScreenIdForLog) { WriteToConsole = writeToConsole, ShowTraceInConsole = isDeveloperMode, diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs index 8713cc48..9f634f45 100644 --- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs @@ -46,6 +46,12 @@ internal class InputHelper : BaseHelper, IInputHelper return this.CurrentInputState().IsSuppressed(button); } + /// + public void Press(SButton button) + { + this.CurrentInputState().OverrideButton(button, setDown: true); + } + /// public void Suppress(SButton button) { diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 2c0f94f0..608d87f6 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -13,6 +13,9 @@ internal class Monitor : IMonitor /********* ** Fields *********/ + /// The mod ID, if applicable. + private readonly string ModId; + /// The name of the module which logs messages using this instance. private readonly string Source; @@ -41,11 +44,17 @@ internal class Monitor : IMonitor /// Whether to log basic contextual info (like buttons pressed and menus opened) even if is disabled. public static bool ForceLogContext { get; set; } + /// The log contexts for which to enable verbose logging regardless of the configured settings. + public static HashSet ForceVerboseLogging { get; } = []; + + /// Whether to force verbose logging for SMAPI and all mods. + public static bool ForceVerboseLoggingForAll { 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; } + public bool IsVerbose => field || Monitor.ForceVerboseLoggingForAll || Monitor.ForceVerboseLogging.Contains(this.ModId); /// Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger. internal bool ShowFullStampInConsole { get; set; } @@ -61,18 +70,20 @@ internal class Monitor : IMonitor ** Public methods *********/ /// Construct an instance. + /// The mod ID, if applicable. /// The name of the module which logs messages using this instance. /// 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, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func getScreenIdForLog) + public Monitor(string modId, string source, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func getScreenIdForLog) { // validate if (string.IsNullOrWhiteSpace(source)) throw new ArgumentException("The log source cannot be empty."); // initialize + this.ModId = modId; 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); @@ -147,7 +158,7 @@ internal class Monitor : IMonitor string consoleMessage = this.ShowFullStampInConsole ? fullMessage : $"[{source}] {message}"; // write to console - if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace)) + if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace || Monitor.ForceVerboseLoggingForAll || Monitor.ForceVerboseLogging.Contains(this.ModId))) this.ConsoleWriter.WriteLine(consoleMessage, level); // write to log file diff --git a/src/SMAPI/IInputHelper.cs b/src/SMAPI/IInputHelper.cs index 053c371e..fdcd383f 100644 --- a/src/SMAPI/IInputHelper.cs +++ b/src/SMAPI/IInputHelper.cs @@ -16,8 +16,22 @@ public interface IInputHelper : IModLinked /// The button. bool IsSuppressed(SButton button); + /// Mark a button as pressed. + /// The button to press. + /// + /// For both mods and the base game, this is equivalent to the button being physically pressed by the player. It will be released on the next input tick by default; it can be pressed again each tick to hold it down. + /// + /// See for the inverse. + /// + void Press(SButton button); + /// Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event. /// The button to suppress. + /// + /// If the button is being held, it'll remain suppressed until the player releases it or until is called for the same button. + /// + /// See for the inverse. + /// void Suppress(SButton button); /// Suppress the immediate change to a scroll wheel value. diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index b4136032..ef8abc73 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; using StardewValley; using StardewValley.Buildings; +using StardewValley.GameData.Characters; using StardewValley.Locations; using StardewValley.Pathfinding; using StardewValley.TerrainFeatures; @@ -74,60 +75,47 @@ internal class CoreAssetPropagator /// 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(IList contentManagers, IDictionary assets, bool ignoreWorld, out IDictionary propagatedAssets, out bool changedWarpRoutes) + public void Propagate(IList contentManagers, IDictionary assets, bool ignoreWorld, out Dictionary propagatedAssets, out bool changedWarpRoutes) { - // get base name lookup - propagatedAssets = assets - .Select(asset => asset.Key.GetBaseAssetName()) - .Distinct() - .ToDictionary(name => name, _ => false); + propagatedAssets = new Dictionary(assets.Count); - // edit textures in-place + // propagate each asset + changedWarpRoutes = false; { - IAssetName[] textureAssets = assets - .Where(p => typeof(Texture2D).IsAssignableFrom(p.Value)) - .Select(p => p.Key) - .ToArray(); + Type imageType = typeof(Texture2D); + Type mapType = typeof(Map); + Dictionary? spouseRoomMapPathCache = null; // constructed later if needed - if (textureAssets.Any()) + foreach ((IAssetName assetName, Type assetType) in assets) { - var defaultLanguage = this.MainContentManager.GetCurrentLanguage(); + bool changed = false; - foreach (IAssetName assetName in textureAssets) + try { - var language = assetName.LanguageCode ?? defaultLanguage; - if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage is null) - language = defaultLanguage; + // image + if (imageType.IsAssignableFrom(assetType)) + changed = this.PropagateTexture(assetName, contentManagers, ignoreWorld); - bool changed = this.PropagateTexture(assetName, language, contentManagers, ignoreWorld); - if (changed) - propagatedAssets[assetName] = true; + // map + else if (assetType == mapType) + { + changed = this.PropagateMap(assetName, ref spouseRoomMapPathCache, ignoreWorld, out bool curChangedMapRoutes); + changedWarpRoutes |= curChangedMapRoutes; + } + + // any other type + else + changed = this.PropagateOther(assetName, ignoreWorld); + } + catch (Exception ex) + { + this.Monitor.Log($"An error occurred while propagating changes to asset '{assetName.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } - foreach (IAssetName assetName in textureAssets) - assets.Remove(assetName); + propagatedAssets[assetName] = changed; } } - // reload other assets - changedWarpRoutes = false; - foreach (var entry in assets) - { - bool changed = false; - bool curChangedMapRoutes = false; - try - { - 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); - } - - propagatedAssets[entry.Key] = changed; - changedWarpRoutes = changedWarpRoutes || curChangedMapRoutes; - } - // reload NPC pathfinding cache if any map routes changed if (changedWarpRoutes) WarpPathfindingCache.PopulateCache(); @@ -137,65 +125,127 @@ internal class CoreAssetPropagator /********* ** Private methods *********/ + /// Propagate changes to a map asset. + /// The asset name that changed. + /// A cache of spouse room map path lookups by farmhouse or cabin instance. This will be created the first time it's needed. + /// 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 any assets were updated. + private bool PropagateMap(IAssetName assetName, ref Dictionary? spouseRoomMapPathCache, bool ignoreWorld, out bool changedWarpRoutes) + { + bool changed = false; + changedWarpRoutes = false; + + if (!ignoreWorld) + { + foreach (LocationInfo info in this.GetLocationsWithInfo()) + { + GameLocation location = info.Location; + + bool shouldUpdateMap = + // edited this map + this.IsSameBaseName(assetName, location.mapPath.Value) + + // edited spouse room for this farmhouse + || ( + location is FarmHouse farmhouse + && this.IsSameBaseName( + assetName, + this.GetDisplayedSpouseRoomPath(farmhouse, ref spouseRoomMapPathCache) + ) + ); + + if (shouldUpdateMap) + { + static ISet GetWarpSet(GameLocation location) + { + HashSet targetNames = []; + + foreach (Warp warp in location.warps) + targetNames.Add(warp.TargetName); + + if (location.doors?.Count() > 0) + { + foreach (string targetName in location.doors.Values) + targetNames.Add(targetName); + } + + return targetNames; + } + + var oldWarps = GetWarpSet(location); + this.UpdateMap(info); + var newWarps = GetWarpSet(location); + + changedWarpRoutes = changedWarpRoutes || oldWarps.Count != newWarps.Count || oldWarps.Any(p => !newWarps.Contains(p)); + changed = true; + } + } + } + + return changed; + } + /// Propagate changes to a cached texture asset. - /// The asset name to reload. - /// The language for which to get assets. + /// The asset name that changed. /// 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) + /// Returns whether any assets were updated. + private bool PropagateTexture(IAssetName assetName, 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 - ? [assetName, assetName.GetBaseAssetName()] - : [assetName]; + // get default language + // This method replaces the textures that would be loaded if you called `contentManager.Load(assetName)`, + // which internally maps to `contentManager.LoadLocalized(assetName, currentLanguage)` regardless of + // the asset name's language. If the asset name includes a locale, `LoadLocalized` handles that internally. + LocalizedContentManager.LanguageCode currentLanguage = LocalizedContentManager.CurrentLanguageCode; - // update textures in-place + // update textures in-place (0 = localized asset name, 1 = base asset name) + for (int i = 0; i < 2; i++) { - // 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)); + bool forLocalizedAsset = i == 0; + + // if the asset name is non-localized, only propagate it once + if (forLocalizedAsset && assetName.LocaleCode is null) + continue; + + // get asset name 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 name = forLocalizedAsset + ? assetName + : assetName.GetBaseAssetName(); + + // get new texture to copy + Lazy newTexture = new(() => + { + if (this.DisposableContentManager.DoesAssetExist(name)) + return this.DisposableContentManager.LoadLocalized(name, currentLanguage, useCache: false); + + this.Monitor.Log($"Skipped reload for '{name.Name}' because the underlying asset no longer exists.", LogLevel.Warn); + return null; + }); // apply to content managers foreach (IContentManager contentManager in contentManagers) { - for (int i = 0; i < assetNames.Length; i++) + if (contentManager.IsLoaded(name)) { - IAssetName name = assetNames[i]; + if (newTexture.Value is null) + break; - if (contentManager.IsLoaded(name)) - { - if (this.DisposableContentManager.DoesAssetExist(name)) - { - changed = true; - - Texture2D texture = contentManager.LoadLocalized(name, language, useCache: true); - texture.CopyFromTexture(newTextures[i].Value); - } - else - { - this.Monitor.Log($"Skipped reload for '{name.Name}' because the underlying asset no longer exists.", LogLevel.Warn); - break; - } - } + Texture2D texture = contentManager.LoadLocalized(name, currentLanguage, useCache: true); + texture.CopyFromTexture(newTexture.Value); + changed = true; } } - // drop temporary textures - foreach (Lazy newTexture in newTextures) - { - if (newTexture.IsValueCreated) - newTexture.Value.Dispose(); - } + // drop temporary texture + if (newTexture.IsValueCreated) + newTexture.Value?.Dispose(); } // update game state if needed @@ -210,8 +260,7 @@ internal class CoreAssetPropagator case "characters/farmer/farmer_base_bald": case "characters/farmer/farmer_girl_base": case "characters/farmer/farmer_girl_base_bald": - if (!ignoreWorld) - this.UpdatePlayerSprites(assetName); + this.UpdatePlayerSprites(assetName); break; /**** @@ -235,69 +284,20 @@ internal class CoreAssetPropagator return changed; } - /// Reload one of the game's core assets (if applicable). - /// The asset name to reload. - /// The asset type to reload. + /// Propagate changes to an asset which isn't a map (handled by ) or texture (handled by ). + /// The asset name that changed. /// 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. + /// Returns whether any assets were updated. [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")] - private bool PropagateOther(IAssetName assetName, Type type, bool ignoreWorld, out bool changedWarpRoutes) + private bool PropagateOther(IAssetName assetName, bool ignoreWorld) { - bool changed = false; var content = this.MainContentManager; - string key = assetName.BaseName; - changedWarpRoutes = false; + string baseName = assetName.BaseName; - /**** - ** Propagate map changes - ****/ - if (type == typeof(Map)) - { - if (!ignoreWorld) - { - foreach (LocationInfo info in this.GetLocationsWithInfo()) - { - GameLocation location = info.Location; - - if (this.IsSameBaseName(assetName, location.mapPath.Value)) - { - static ISet GetWarpSet(GameLocation location) - { - HashSet targetNames = []; - - foreach (Warp warp in location.warps) - targetNames.Add(warp.TargetName); - - if (location.doors?.Count() > 0) - { - foreach (string targetName in location.doors.Values) - targetNames.Add(targetName); - } - - return targetNames; - } - - var oldWarps = GetWarpSet(location); - this.UpdateMap(info); - var newWarps = GetWarpSet(location); - - changedWarpRoutes = changedWarpRoutes || oldWarps.Count != newWarps.Count || oldWarps.Any(p => !newWarps.Contains(p)); - changed = true; - } - } - } - - return changed; - } - - /**** - ** Propagate by key - ****/ - switch (assetName.BaseName.ToLower().Replace("\\", "/")) // normalized key so we can compare statically + switch (baseName.ToLower().Replace("\\", "/")) // normalized key so we can compare statically { /**** - ** Content\Data + ** Content/Data ****/ case "data/achievements": // Game1.LoadContent Game1.achievements = DataLoader.Achievements(content); @@ -386,7 +386,7 @@ internal class CoreAssetPropagator return true; case "data/hairdata": // Farmer.GetHairStyleMetadataFile - return changed | this.UpdateHairData(); + return this.UpdateHairData(); case "data/hats": // HatDataDefinition ItemRegistry.ResetCache(); @@ -456,25 +456,25 @@ internal class CoreAssetPropagator return true; /**** - ** Content\Fonts + ** Content/Fonts ****/ case "fonts/spritefont1": // Game1.LoadContent - Game1.dialogueFont = content.Load(key); + Game1.dialogueFont = content.Load(baseName); return true; case "fonts/smallfont": // Game1.LoadContent - Game1.smallFont = content.Load(key); + Game1.smallFont = content.Load(baseName); return true; case "fonts/tinyfont": // Game1.LoadContent - Game1.tinyFont = content.Load(key); + Game1.tinyFont = content.Load(baseName); return true; /**** - ** Content\Strings + ** Content/Strings ****/ case "strings/stringsfromcsfiles": - return changed | this.UpdateStringsFromCsFiles(content); + return this.UpdateStringsFromCsFiles(content); /**** ** Dynamic keys @@ -483,10 +483,10 @@ internal class CoreAssetPropagator if (!ignoreWorld) { if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) - return changed | this.UpdateNpcDialogue(assetName); + return this.UpdateNpcDialogue(assetName); if (assetName.IsDirectlyUnderPath("Characters/schedules")) - return changed | this.UpdateNpcSchedules(assetName); + return this.UpdateNpcSchedules(assetName); } return false; @@ -529,19 +529,19 @@ internal class CoreAssetPropagator /// The asset name to update. private void UpdatePlayerSprites(IAssetName assetName) { - Farmer[] players = - ( - from player in Game1.getOnlineFarmers() - where this.IsSameBaseName(assetName, player.getTexture()) - select player - ) - .ToArray(); + // reset recolors + FarmerRenderer.recolorOffsets?.Clear(); - foreach (Farmer player in players) + // reset local player + // This is handled separately since Game1.getOnlineFarmers() doesn't include the local player before the save is loaded + if (this.IsSameBaseName(assetName, Game1.player?.getTexture())) + Game1.player.FarmerRenderer.MarkSpriteDirty(); + + // reset other player + foreach (Farmer player in Game1.getOnlineFarmers()) { - FarmerRenderer.recolorOffsets?.Clear(); - - player.FarmerRenderer.MarkSpriteDirty(); + if (!object.ReferenceEquals(player, Game1.player) && this.IsSameBaseName(assetName, player.getTexture())) + player.FarmerRenderer.MarkSpriteDirty(); } } @@ -701,6 +701,8 @@ internal class CoreAssetPropagator // reapply map changes // This must happen after updating warps (since some map modifications like the community shortcuts add warps) // and after resetting interior doors' state (so they apply their modifications too). + if (location is FarmHouse) + this.Reflection.GetField(location, "displayingSpouseRoom").SetValue(false); location.MakeMapModifications(force: true); // reset player position @@ -808,10 +810,37 @@ internal class CoreAssetPropagator }); } + /// Get the asset name for a farmhouse's spouse room, if it's currently displaying one. + /// The farmhouse whose spouse room to get. + /// A cache of spouse room map path lookups by farmhouse or cabin instance. This is created if it's null. + private string? GetDisplayedSpouseRoomPath(FarmHouse farmhouse, ref Dictionary? cache) + { + // from cache + if (cache is null) + cache = []; + else if (cache.TryGetValue(farmhouse, out string? spouseRoomMapPath)) + return spouseRoomMapPath; + + // no spouse room shown + Farmer? owner = farmhouse.owner; + if (owner?.spouse is null || !this.Reflection.GetField(farmhouse, "displayingSpouseRoom").GetValue()) + { + cache[farmhouse] = null; + return null; + } + + // else get map path + string mapPath = NPC.TryGetData(owner.spouse, out CharacterData? spouseData) && spouseData?.SpouseRoom?.MapAsset is { } mapName + ? $"Maps/{mapName}" + : "Maps/spouseRooms"; + cache[farmhouse] = mapPath; + return mapPath; + } + /// Get whether two asset names are equivalent if you ignore the locale code. /// The first value to compare. /// The second value to compare. - private bool IsSameBaseName(IAssetName? left, string? right) + private bool IsSameBaseName([NotNullWhen(true)] IAssetName? left, [NotNullWhen(true)] string? right) { if (left is null || right is null) return false; @@ -823,7 +852,7 @@ internal class CoreAssetPropagator /// Get whether two asset names are equivalent if you ignore the locale code. /// The first value to compare. /// The second value to compare. - private bool IsSameBaseName(IAssetName? left, IAssetName? right) + private bool IsSameBaseName([NotNullWhen(true)] IAssetName? left, [NotNullWhen(true)] IAssetName? right) { if (left is null || right is null) return false; diff --git a/src/SMAPI/PatchMode.cs b/src/SMAPI/PatchMode.cs index 8575e5d0..352df15c 100644 --- a/src/SMAPI/PatchMode.cs +++ b/src/SMAPI/PatchMode.cs @@ -7,5 +7,9 @@ public enum PatchMode Replace, /// Draw the new content over the original content, so the original content shows through any transparent or semi-transparent pixels. - Overlay + Overlay, + + /// Apply the new content over the original content as a transparency mask. + /// This subtracts the alpha value of each pixel in the new content from the corresponding pixel in the original content. Colors in the new content are ignored. For example, a fully opaque pixel in the new content will result in a fully transparent pixel in the final image. + Mask }