Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard
2026-01-10 20:23:06 -05:00
64 changed files with 1019 additions and 612 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed.
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!--set general build properties -->
<Version>4.3.2</Version>
<Version>4.4.0</Version>
<Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
-3
View File
@@ -39,9 +39,6 @@ This assumes `find-game-folder.targets` has already been imported and validated.
<!-- FluentHttpClient + dependencies -->
<Copy SourceFiles="$(TargetDir)\Pathoschild.Http.Client.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\System.Net.Http.Formatting.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<!-- .NET dependencies -->
<Copy SourceFiles="$(TargetDir)\System.Management.dll" DestinationFolder="$(GamePath)\smapi-internal" Condition="$(OS) == 'Windows_NT'" />
</Target>
<!-- .NET metadata files -->
-5
View File
@@ -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"
@@ -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"
+33
View File
@@ -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).
+13 -14
View File
@@ -5,36 +5,35 @@
<ItemGroup>
<!-- SMAPI + toolkit -->
<PackageVersion Include="HtmlAgilityPack" Version="1.12.1" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="17.10.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.9.2" />
<PackageVersion Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageVersion Include="Mono.Cecil" Version="0.11.6" />
<PackageVersion Include="MonoMod.Common" Version="22.3.5.1" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Pathoschild.Http.FluentClient" Version="4.4.2" />
<PackageVersion Include="Pintail" Version="2.8.1" />
<PackageVersion Include="Pintail" Version="2.9.1" />
<PackageVersion Include="Platonymous.TMXTile" Version="1.5.9" />
<PackageVersion Include="System.Management" Version="9.0.5" />
<PackageVersion Include="System.Reflection.Emit" Version="4.7.0" />
<PackageVersion Include="VdfConverter" Version="1.0.3" />
<!-- tests only -->
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NUnit" Version="4.3.2" />
<PackageVersion Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageVersion Include="NUnit" Version="4.4.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.0.1" />
<!-- web only -->
<PackageVersion Include="Azure.Storage.Blobs" Version="12.24.0" />
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.20" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.26.0" />
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.22" />
<PackageVersion Include="Hangfire.Console" Version="1.4.3" />
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.1" />
<PackageVersion Include="Humanizer.Core" Version="2.14.1" />
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageVersion Include="Markdig" Version="0.41.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.16" />
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.2" />
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.1" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="Pathoschild.FluentNexus" Version="1.0.5" />
</ItemGroup>
@@ -15,7 +15,20 @@ internal class ApplySaveFixCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
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 <fix ID>\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 <fix ID>
Apply the named save fix.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -18,7 +18,19 @@ internal class RegenerateBundlesCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
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 [<type>] [ignore_seed]\nRegenerate all bundles for this save. If the <type> 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 [<type>] [ignore_seed]
Regenerate all bundles for this save. If the <type> 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
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -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;
/// <summary>A command which toggles verbose mode in the SMAPI log.</summary>
internal class SetVerboseCommand : ConsoleCommand
{
/*********
** Fields
*********/
/// <summary>The mod register with which to validate mod IDs.</summary>
private readonly IModRegistry ModRegistry;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modRegistry">The mod register with which to validate mod IDs.</param>
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;
}
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="command">The command name.</param>
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// parse 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<string> toggled = [];
List<string> 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);
}
}
}
}
@@ -20,7 +20,28 @@ internal class AddCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public AddCommand()
: base("player_add", AddCommand.GetDescription()) { }
: base(
name: "player_add",
description:
$"""
Gives the player an item.
Usage: player_add <item id> [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 "<item 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"
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -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;
}
/// <summary>Get the command description.</summary>
private static string GetDescription()
{
return
$"""
Gives the player an item.
Usage: player_add <item id> [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 "<item 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"
""";
}
}
@@ -21,7 +21,17 @@ internal class ListItemsCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
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.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,18 @@ internal class SetColorCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetColorCommand()
: base("player_changecolor", "Sets the color of a player feature.\n\nUsage: player_changecolor <target> <color>\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> <color>
- target: what to change (one of 'hair', 'eyes', or 'pants').
- color: a color value in RGB format, like (255,255,255).
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -19,7 +19,17 @@ internal class SetFarmTypeCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetFarmTypeCommand()
: base("set_farm_type", "Sets the current player's farm type.\n\nUsage: set_farm_type <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>
- farm type: the farm type to set. Enter `set_farm_type list` for a list of available farm types.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,17 @@ internal class SetHealthCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetHealthCommand()
: base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount.") { }
: base(
name: "player_sethealth",
description:
"""
Sets the player's health.
Usage: player_sethealth [value]
- value: an integer amount.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,17 @@ internal class SetMaxHealthCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
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.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,17 @@ internal class SetMaxStaminaCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
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.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,17 @@ internal class SetMoneyCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetMoneyCommand()
: base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount.") { }
: base(
name: "player_setmoney",
description:
"""
Sets the player's money.
Usage: player_setmoney <value>
- value: an integer amount.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -12,7 +12,18 @@ internal class SetNameCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetNameCommand()
: base("player_setname", "Sets the player's name.\n\nUsage: player_setname <target> <name>\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> <name>
- target: what to rename (one of 'player' or 'farm').
- name: the new name to set.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,17 @@ internal class SetStaminaCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetStaminaCommand()
: base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount.") { }
: base(
name: "player_setstamina",
description:
"""
Sets the player's stamina.
Usage: player_setstamina [value]
- value: an integer amount.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -12,7 +12,18 @@ internal class SetStyleCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetStyleCommand()
: base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle <target> <value>.\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> <value>
- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').
- value: the style ID.
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -21,7 +21,18 @@ internal class FreezeTimeCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public FreezeTimeCommand()
: base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", 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
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -14,7 +14,17 @@ internal class SetDayCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetDayCommand()
: base("world_setday", "Sets the day to the specified value.\n\nUsage: world_setday <value>.\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>.
- value: the target day (a number from 1 to 28).
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -13,7 +13,17 @@ internal class SetMineLevelCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetMineLevelCommand()
: base("world_setminelevel", "Sets the mine level?\n\nUsage: world_setminelevel <value>\n- value: The target level (a number starting at 1).") { }
: base(
name: "world_setminelevel",
description:
"""
Sets the mine level?
Usage: world_setminelevel <value>
- value: The target level (a number starting at 1).
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -21,7 +21,17 @@ internal class SetSeasonCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetSeasonCommand()
: base("world_setseason", "Sets the season to the specified value.\n\nUsage: world_setseason <season>\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>
- season: the target season (one of 'spring', 'summer', 'fall', 'winter').
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -14,7 +14,17 @@ internal class SetTimeCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetTimeCommand()
: base("world_settime", "Sets the time to the specified value.\n\nUsage: world_settime <value>\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>
- value: the target time in military time (like 0600 for 6am and 1800 for 6pm).
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -14,7 +14,17 @@ internal class SetYearCommand : ConsoleCommand
*********/
/// <summary>Construct an instance.</summary>
public SetYearCommand()
: base("world_setyear", "Sets the year to the specified value.\n\nUsage: world_setyear <year>\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>
- year: the target year (a number starting from 1).
"""
)
{ }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -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
+11 -5
View File
@@ -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
/// <summary>Find all commands in the assembly.</summary>
private IEnumerable<IConsoleCommand> 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)!;
}
}
}
+2 -2
View File
@@ -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"
}
+2 -2
View File
@@ -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"
}
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
@@ -57,7 +57,7 @@ public class CompatibilityRepoClient : IDisposable
/// <summary>Fetch mods from the compatibility list by reading a local copy of the compatibility list repo.</summary>
/// <param name="gitRepoPath">The full path to the compatibility list repo folder.</param>
public async Task<ModCompatibilityEntry[]> FetchModsFromLocalGitFolderAsync(string gitRepoPath)
public Task<ModCompatibilityEntry[]> 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<RawCompatibilityList>(File.ReadAllText(modsJsonPath));
RawCompatibilityList? brokenContentPacks = JsonConvert.DeserializeObject<RawCompatibilityList>(File.ReadAllText(contentPacksJsonPath));
return
return Task.FromResult(
(mods?.Mods ?? [])
.Concat(brokenContentPacks?.BrokenContentPacks ?? [])
.Select(this.ParseRawModEntry)
.ToArray();
.Concat(brokenContentPacks?.BrokenContentPacks ?? [])
.Select(this.ParseRawModEntry)
.ToArray()
);
}
/// <summary>Get the inline HTML produced by a Markdown string in a compatibility repo field.</summary>
@@ -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()
@@ -45,9 +45,12 @@ public class ModCompatibilityEntry
/// <summary>The mod's compatibility with the latest stable version of the game.</summary>
public ModCompatibilityInfo Compatibility { get; }
/// <summary>The human-readable warnings for players about this mod.</summary>
/// <summary>The human-readable warnings for players about this mod, in Markdown format.</summary>
public string[] Warnings { get; }
/// <summary>The HTML version of <see cref="Warnings"/>, if different.</summary>
public string[]? HtmlWarnings { get; }
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests.</summary>
public string? DevNote { get; }
@@ -62,23 +65,24 @@ public class ModCompatibilityEntry
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="id">The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order.</param>
/// <param name="name">The mod's display name. If the mod has multiple names, the first one is the most canonical name.</param>
/// <param name="author">The mod's author name. If the author has multiple names, the first one is the most canonical name.</param>
/// <param name="nexusId">The mod ID on Nexus.</param>
/// <param name="chucklefishId">The mod ID in the Chucklefish mod repo.</param>
/// <param name="curseForgeId">The mod ID in the CurseForge mod repo.</param>
/// <param name="modDropId">The mod ID in the ModDrop mod repo.</param>
/// <param name="githubRepo">The GitHub repository in the form 'owner/repo'.</param>
/// <param name="customSourceUrl">The URL to a non-GitHub source repo.</param>
/// <param name="customUrl">The custom mod page URL (if applicable).</param>
/// <param name="contentPackFor">The name of the mod which loads this content pack, if applicable.</param>
/// <param name="compatibility">The mod's compatibility with the latest stable version of the game.</param>
/// <param name="warnings">The human-readable warnings for players about this mod.</param>
/// <param name="devNote">Special notes intended for developers who maintain unofficial updates or submit pull requests.</param>
/// <param name="overrides">The data overrides to apply to the mod's manifest or remote mod page data, if any.</param>
/// <param name="anchor">The link anchor for the mod entry in the compatibility list.</param>
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)
/// <param name="id"><inheritdoc cref="ID" path="/summary"/></param>
/// <param name="name"><inheritdoc cref="Name" path="/summary"/></param>
/// <param name="author"><inheritdoc cref="Author" path="/summary"/></param>
/// <param name="nexusId"><inheritdoc cref="NexusID" path="/summary"/></param>
/// <param name="chucklefishId"><inheritdoc cref="ChucklefishID" path="/summary"/></param>
/// <param name="curseForgeId"><inheritdoc cref="CurseForgeID" path="/summary"/></param>
/// <param name="modDropId"><inheritdoc cref="ModDropID" path="/summary"/></param>
/// <param name="githubRepo"><inheritdoc cref="GitHubRepo" path="/summary"/></param>
/// <param name="customSourceUrl"><inheritdoc cref="CustomSourceUrl" path="/summary"/></param>
/// <param name="customUrl"><inheritdoc cref="CustomUrl" path="/summary"/></param>
/// <param name="contentPackFor"><inheritdoc cref="ContentPackFor" path="/summary"/></param>
/// <param name="compatibility"><inheritdoc cref="Compatibility" path="/summary"/></param>
/// <param name="warnings"><inheritdoc cref="Warnings" path="/summary"/></param>
/// <param name="htmlWarnings"><inheritdoc cref="HtmlWarnings" path="/summary"/></param>
/// <param name="devNote"><inheritdoc cref="DevNote" path="/summary"/></param>
/// <param name="overrides"><inheritdoc cref="Overrides" path="/summary"/></param>
/// <param name="anchor"><inheritdoc cref="Anchor" path="/summary"/></param>
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, string>(ModSiteKey.GitHub, $"https://github.com/{this.GitHubRepo}/releases");
}
/// <summary>Get whether this compatibility entry refers to the given mod site page ID.</summary>
/// <param name="site">The mod site.</param>
/// <param name="id">The mod page ID.</param>
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
};
}
}
@@ -24,18 +24,22 @@ public class ModCompatibilityInfo
/// <summary>The URL to the latest unofficial update, if applicable.</summary>
public string? UnofficialUrl { get; }
/// <summary>If the compatibility status is <see cref="ModCompatibilityStatus.Abandoned"/>, the reason it was abandoned.</summary>
public ModCompatibilityReasonAbandoned AbandonedReason { get; }
/*********
** Accessors
*********/
/// <summary>Construct an instance.</summary>
/// <param name="status">The compatibility status.</param>
/// <param name="summary">The human-readable summary of the compatibility status or workaround, in Markdown format.</param>
/// <param name="htmlSummary">An HTML version of <paramref name="summary"/>, if different.</param>
/// <param name="brokeIn">The game or SMAPI version which broke this mod, if applicable.</param>
/// <param name="unofficialVersion">The version of the latest unofficial update, if applicable.</param>
/// <param name="unofficialUrl">The URL to the latest unofficial update, if applicable.</param>
public ModCompatibilityInfo(ModCompatibilityStatus status, string? summary, string? htmlSummary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl)
/// <param name="status"><inheritdoc cref="Status" path="/summary"/></param>
/// <param name="summary"><inheritdoc cref="Summary" path="/summary"/></param>
/// <param name="htmlSummary"><inheritdoc cref="HtmlSummary" path="/summary"/></param>
/// <param name="brokeIn"><inheritdoc cref="BrokeIn" path="/summary"/></param>
/// <param name="unofficialVersion"><inheritdoc cref="UnofficialVersion" path="/summary"/></param>
/// <param name="unofficialUrl"><inheritdoc cref="UnofficialUrl" path="/summary"/></param>
/// <param name="abandonedReason"><inheritdoc cref="AbandonedReason" path="/summary"/></param>
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;
}
/// <summary>Get whether this mod is compatible with no notes.</summary>
@@ -0,0 +1,17 @@
namespace StardewModdingAPI.Toolkit.Framework.Clients.CompatibilityRepo;
/// <summary>The reason a mod is marked abandoned.</summary>
public enum ModCompatibilityReasonAbandoned
{
/// <summary>Not applicable.</summary>
None,
/// <summary>The mod has been explicitly and definitively abandoned by the author.</summary>
Author,
/// <summary>The mod pages or files have been deleted, so the mod is no longer available.</summary>
Deleted,
/// <summary>The mod pages have been hidden, so the mod is no longer available.</summary>
Hidden
}
@@ -45,7 +45,7 @@ public class RawModEntry
public string? Source { get; set; }
/// <summary>Custom text indicating compatibility issues with the mod (e.g. not compatible with Linux/Mac).</summary>
public string[]? Warnings { get; set; }
public string?[]? Warnings { get; set; }
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests.</summary>
public string? DeveloperNotes { get; set; }
@@ -65,6 +65,9 @@ public class RawModEntry
/// <summary>The unofficial update which fixes compatibility with the latest Stardew Valley and SMAPI versions.</summary>
public RawModUnofficialUpdate? UnofficialUpdate { get; set; }
/// <summary>If the compatibility status is <see cref="ModCompatibilityStatus.Abandoned"/>, the reason it was abandoned.</summary>
public string? AbandonedReason { get; set; }
/****
** Content packs only
****/
@@ -105,6 +108,14 @@ public class RawModEntry
return status;
}
/// <summary>Get the reason the mod was abandoned, if applicable.</summary>
public ModCompatibilityReasonAbandoned GetReasonAbandoned()
{
return Enum.TryParse(this.AbandonedReason, true, out ModCompatibilityReasonAbandoned reason)
? reason
: ModCompatibilityReasonAbandoned.None;
}
/// <summary>Get the compatibility summary as shown on the compatibility list.</summary>
/// <param name="summary">The compatibility summary.</param>
/// <param name="mayBeMarkdown">Whether the summary may contain Markdown formatting.</param>
@@ -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");
@@ -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
/// <param name="platform">The current platform.</param>
public static string GetFriendlyPlatformName(string platform)
{
#if SMAPI_FOR_WINDOWS
try
{
string? result = new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem")
.Get()
.Cast<ManagementObject>()
.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;
}
@@ -10,13 +10,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning;
/// <summary>The info about a mod read from its folder.</summary>
public class ModFolder
{
/*********
** Fields
*********/
/// <summary>The backing field for <see cref="Directory"/>.</summary>
private DirectoryInfo? DirectoryImpl;
/*********
** Accessors
*********/
@@ -28,7 +21,7 @@ public class ModFolder
/// <summary>The folder containing the mod's manifest.json.</summary>
[JsonIgnore]
public DirectoryInfo Directory => this.DirectoryImpl ??= new DirectoryInfo(this.DirectoryPath);
public DirectoryInfo Directory => field ??= new DirectoryInfo(this.DirectoryPath);
/// <summary>The mod type.</summary>
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;
-1
View File
@@ -15,7 +15,6 @@
<PackageReference Include="Markdig" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Pathoschild.Http.FluentClient" />
<PackageReference Include="System.Management" Condition="'$(OS)' == 'Windows_NT'" />
<PackageReference Include="Microsoft.Win32.Registry" Condition="'$(OS)' == 'Windows_NT'" />
<PackageReference Include="VdfConverter" Condition="'$(OS)' == 'Windows_NT'" Private="False" />
</ItemGroup>
-1
View File
@@ -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;
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<AssemblyName>SMAPI.Web</AssemblyName>
<RootNamespace>StardewModdingAPI.Web</RootNamespace>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
+16 -13
View File
@@ -53,18 +53,18 @@ public class ModModel
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="id">The mod IDs.</param>
/// <param name="name">The mod name.</param>
/// <param name="alternateNames">The mod's alternative names, if any.</param>
/// <param name="author">The mod author's name.</param>
/// <param name="alternateAuthors">The mod author's alternative names, if any.</param>
/// <param name="gitHubRepo">The GitHub repo, if any.</param>
/// <param name="sourceUrl">The URL to the mod's source code, if any.</param>
/// <param name="compatibility">The compatibility status for the stable version of the game.</param>
/// <param name="modPages">Links to the available mod pages.</param>
/// <param name="warnings">The human-readable warnings for players about this mod.</param>
/// <param name="devNote">Special notes intended for developers who maintain unofficial updates or submit pull requests.</param>
/// <param name="slug">A unique identifier for the mod that can be used in an anchor URL.</param>
/// <param name="id"><inheritdoc cref="Id" path="/summary"/></param>
/// <param name="name"><inheritdoc cref="Name" path="/summary"/></param>
/// <param name="alternateNames"><inheritdoc cref="AlternateNames" path="/summary"/></param>
/// <param name="author"><inheritdoc cref="Author" path="/summary"/></param>
/// <param name="alternateAuthors"><inheritdoc cref="AlternateAuthors" path="/summary"/></param>
/// <param name="gitHubRepo"><inheritdoc cref="GitHubRepo" path="/summary"/></param>
/// <param name="sourceUrl"><inheritdoc cref="SourceUrl" path="/summary"/></param>
/// <param name="compatibility"><inheritdoc cref="Compatibility" path="/summary"/></param>
/// <param name="modPages"><inheritdoc cref="ModPages" path="/summary"/></param>
/// <param name="warnings"><inheritdoc cref="Warnings" path="/summary"/></param>
/// <param name="devNote"><inheritdoc cref="DevNote" path="/summary"/></param>
/// <param name="slug"><inheritdoc cref="Slug" path="/summary"/></param>
[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;
}
+1 -2
View File
@@ -1,5 +1,4 @@
@using Humanizer
@using Humanizer.Localisation
@using StardewModdingAPI.Web.Framework
@model StardewModdingAPI.Web.ViewModels.ModListModel
@{
@@ -109,7 +108,7 @@ else
</td>
<td>
<div v-html="mod.Compatibility.Summary"></div>
<div v-if="mod.Warnings" v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div>
<div v-if="mod.Warnings" v-for="(warning, i) in mod.Warnings">⚠ <span v-html="warning"></span></div>
</td>
<td class="mod-broke-in" v-html="mod.Compatibility.BrokeIn" v-show="showAdvanced"></td>
<td v-show="showAdvanced">
@@ -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",
-194
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
<Solution>
<Folder Name="/.root/">
<File Path="../.editorconfig" />
<File Path="../.gitattributes" />
<File Path="../.gitignore" />
<File Path="../LICENSE.txt" />
<File Path="Directory.Packages.props" />
</Folder>
<Folder Name="/.root/.github/">
<File Path="../.github/CONTRIBUTING.md" />
<File Path="../.github/SUPPORT.md" />
</Folder>
<Folder Name="/.root/.github/ISSUE_TEMPLATE/">
<File Path="../.github/ISSUE_TEMPLATE/config.yml" />
<File Path="../.github/ISSUE_TEMPLATE/custom.md" />
</Folder>
<Folder Name="/.root/build/">
<File Path="../build/common.targets" />
<File Path="../build/deploy-local-smapi.targets" />
<File Path="../build/find-game-folder.targets" />
</Folder>
<Folder Name="/.root/build/unix/">
<File Path="../build/unix/prepare-install-package.sh" />
<File Path="../build/unix/set-smapi-version.sh" />
</Folder>
<Folder Name="/.root/build/windows/">
<File Path="../build/windows/finalize-install-package.sh" />
<File Path="../build/windows/prepare-install-package.ps1" />
<File Path="../build/windows/set-smapi-version.ps1" />
</Folder>
<Folder Name="/.root/build/windows/lib/">
<File Path="../build/windows/lib/in-place-regex.ps1" />
</Folder>
<Folder Name="/.root/docs/">
<File Path="../docs/mod-build-config.md" />
<File Path="../docs/README.md" />
<File Path="../docs/release-notes-archived.md" />
<File Path="../docs/release-notes.md" />
</Folder>
<Folder Name="/.root/docs/technical/">
<File Path="../docs/technical/mod-package-release-notes.md" />
<File Path="../docs/technical/mod-package.md" />
<File Path="../docs/technical/smapi.md" />
<File Path="../docs/technical/web.md" />
</Folder>
<Folder Name="/Internal/">
<Project Path="SMAPI.Internal/SMAPI.Internal.shproj" />
<Project Path="SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj" />
<Project Path="SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj" />
<Project Path="SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj" />
<Project Path="SMAPI.Tests/SMAPI.Tests.csproj" />
</Folder>
<Folder Name="/Mods/">
<Project Path="SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj" />
<Project Path="SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj" />
</Folder>
<Project Path="SMAPI.Installer/SMAPI.Installer.csproj">
<BuildDependency Project="SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj" />
<BuildDependency Project="SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj" />
<BuildDependency Project="SMAPI/SMAPI.csproj" />
</Project>
<Project Path="SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj" />
<Project Path="SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj" />
<Project Path="SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj" />
<Project Path="SMAPI.Toolkit/SMAPI.Toolkit.csproj" />
<Project Path="SMAPI.Web/SMAPI.Web.csproj" />
<Project Path="SMAPI/SMAPI.csproj" />
</Solution>
+1 -1
View File
@@ -49,7 +49,7 @@ internal static class EarlyConstants
internal static int? LogScreenId { get; set; }
/// <summary>SMAPI's current raw semantic version.</summary>
internal static string RawApiVersion = "4.3.2";
internal static string RawApiVersion = "4.4.0";
}
/// <summary>Contains SMAPI's constants and assumptions.</summary>
@@ -17,7 +17,16 @@ internal class HarmonySummaryCommand : IInternalCommand
public string Name { get; } = "harmony_summary";
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
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 <search>\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>
- 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.
""";
/*********
+10 -1
View File
@@ -19,7 +19,16 @@ internal class HelpCommand : IInternalCommand
public string Name { get; } = "help";
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
public string Description { get; } = "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\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>
- cmd: The name of a command whose documentation to display.
""";
/*********
@@ -21,7 +21,12 @@ internal class ReloadI18nCommand : IInternalCommand
public string Name { get; } = "reload_i18n";
/// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary>
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
""";
/*********
@@ -205,30 +205,53 @@ internal class AssetDataForImage : AssetData<Texture2D>, 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
);
}
}
}
+1 -4
View File
@@ -12,9 +12,6 @@ internal class AssetInfo : IAssetInfo
/// <summary>Normalizes an asset key to match the cache key.</summary>
protected readonly Func<string, string> GetNormalizedPath;
/// <summary>The backing field for <see cref="NameWithoutLocale"/>.</summary>
private IAssetName? NameWithoutLocaleImpl;
/*********
** Accessors
@@ -26,7 +23,7 @@ internal class AssetInfo : IAssetInfo
public IAssetName Name { get; }
/// <inheritdoc />
public IAssetName NameWithoutLocale => this.NameWithoutLocaleImpl ??= this.Name.GetBaseAssetName();
public IAssetName NameWithoutLocale => field ??= this.Name.GetBaseAssetName();
/// <inheritdoc />
public Type DataType { get; }
+1 -1
View File
@@ -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<IAssetName, bool> propagated,
out Dictionary<IAssetName, bool> propagated,
out bool updatedWarpRoutes
);
@@ -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
*********/
/// <summary>A path segment which navigates to the parent directory.</summary>
private const string DirectoryClimbingPathSegment = "..";
/// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
private readonly JsonHelper JsonHelper;
@@ -352,38 +354,6 @@ internal sealed class ModContentManager : BaseContentManager
return file;
}
/// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game.</summary>
/// <param name="texture">The texture to premultiply.</param>
/// <returns>Returns a premultiplied texture.</returns>
/// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks>
private void PremultiplyTransparency(Texture2D texture)
{
int count = texture.Width * texture.Height;
Color[] data = ArrayPool<Color>.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<Color>.Shared.Return(data);
}
}
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
/// <param name="map">The map whose tilesheets to fix.</param>
/// <param name="relativeMapPath">The relative map path within the mod folder.</param>
@@ -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
/// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param>
/// <param name="relativePath">The tilesheet path to load.</param>
/// <param name="assetName">The found asset name.</param>
/// <param name="error">A message indicating why the file couldn't be loaded.</param>
/// <param name="error">A message indicating why the file couldn't be loaded, if applicable.</param>
/// <returns>Returns whether the asset name was found.</returns>
/// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks>
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<Texture2D>(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;
}
/// <summary>Get an exception indicating that a map asset can't be loaded because one of its tilesheets has an invalid path.</summary>
/// <param name="relativeMapPath">The relative path to the folder which contains the map asset, relative to the mod folder.</param>
/// <param name="imageSource">The tilesheet's path to the image file.</param>
/// <param name="error">The error message indicating why loading it failed. (Basic metadata like the image source is prepended automatically.)</param>
/// <param name="ex">The inner exception which caused the error, if applicable.</param>
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);
}
}
+1 -1
View File
@@ -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,
@@ -46,6 +46,12 @@ internal class InputHelper : BaseHelper, IInputHelper
return this.CurrentInputState().IsSuppressed(button);
}
/// <inheritdoc />
public void Press(SButton button)
{
this.CurrentInputState().OverrideButton(button, setDown: true);
}
/// <inheritdoc />
public void Suppress(SButton button)
{
+14 -3
View File
@@ -13,6 +13,9 @@ internal class Monitor : IMonitor
/*********
** Fields
*********/
/// <summary>The mod ID, if applicable.</summary>
private readonly string ModId;
/// <summary>The name of the module which logs messages using this instance.</summary>
private readonly string Source;
@@ -41,11 +44,17 @@ internal class Monitor : IMonitor
/// <summary>Whether to log basic contextual info (like buttons pressed and menus opened) even if <see cref="IsVerbose"/> is disabled.</summary>
public static bool ForceLogContext { get; set; }
/// <summary>The log contexts for which to enable verbose logging regardless of the configured settings.</summary>
public static HashSet<string> ForceVerboseLogging { get; } = [];
/// <summary>Whether to force verbose logging for SMAPI and all mods.</summary>
public static bool ForceVerboseLoggingForAll { get; set; }
/// <summary>The current log level for contextual info that's relevant to the <see cref="ForceLogContext"/> flag.</summary>
public static LogLevel ContextLogLevel => Monitor.ForceLogContext ? LogLevel.Info : LogLevel.Trace;
/// <inheritdoc />
public bool IsVerbose { get; }
public bool IsVerbose => field || Monitor.ForceVerboseLoggingForAll || Monitor.ForceVerboseLogging.Contains(this.ModId);
/// <summary>Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger.</summary>
internal bool ShowFullStampInConsole { get; set; }
@@ -61,18 +70,20 @@ internal class Monitor : IMonitor
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modId">The mod ID, if applicable.</param>
/// <param name="source">The name of the module which logs messages using this instance.</param>
/// <param name="logFile">The log file to which to write messages.</param>
/// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param>
/// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param>
/// <param name="getScreenIdForLog">Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</param>
public Monitor(string source, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func<int?> getScreenIdForLog)
public Monitor(string modId, string source, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func<int?> 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
+14
View File
@@ -16,8 +16,22 @@ public interface IInputHelper : IModLinked
/// <param name="button">The button.</param>
bool IsSuppressed(SButton button);
/// <summary>Mark a button as pressed.</summary>
/// <param name="button">The button to press.</param>
/// <remarks>
/// <para>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.</para>
///
/// <para>See <see cref="Suppress"/> for the inverse.</para>
/// </remarks>
void Press(SButton button);
/// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary>
/// <param name="button">The button to suppress.</param>
/// <remarks>
/// <para>If the button is being held, it'll remain suppressed until the player releases it or until <see cref="Press"/> is called for the same button.</para>
///
/// <para>See <see cref="Press"/> for the inverse.</para>
/// </remarks>
void Suppress(SButton button);
/// <summary>Suppress the immediate change to a scroll wheel value.</summary>
+192 -163
View File
@@ -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
/// <param name="ignoreWorld">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.</param>
/// <param name="propagatedAssets">A lookup of asset names to whether they've been propagated.</param>
/// <param name="changedWarpRoutes">Whether the NPC pathfinding warp route cache was reloaded.</param>
public void Propagate(IList<IContentManager> contentManagers, IDictionary<IAssetName, Type> assets, bool ignoreWorld, out IDictionary<IAssetName, bool> propagatedAssets, out bool changedWarpRoutes)
public void Propagate(IList<IContentManager> contentManagers, IDictionary<IAssetName, Type> assets, bool ignoreWorld, out Dictionary<IAssetName, bool> propagatedAssets, out bool changedWarpRoutes)
{
// get base name lookup
propagatedAssets = assets
.Select(asset => asset.Key.GetBaseAssetName())
.Distinct()
.ToDictionary(name => name, _ => false);
propagatedAssets = new Dictionary<IAssetName, bool>(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<FarmHouse, string?>? 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
*********/
/// <summary>Propagate changes to a map asset.</summary>
/// <param name="assetName">The asset name that changed.</param>
/// <param name="spouseRoomMapPathCache">A cache of spouse room map path lookups by farmhouse or cabin instance. This will be created the first time it's needed.</param>
/// <param name="ignoreWorld">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.</param>
/// <param name="changedWarpRoutes">Whether the locations reachable by warps from this location changed as part of this propagation.</param>
/// <returns>Returns whether any assets were updated.</returns>
private bool PropagateMap(IAssetName assetName, ref Dictionary<FarmHouse, string?>? 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<string> GetWarpSet(GameLocation location)
{
HashSet<string> 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;
}
/// <summary>Propagate changes to a cached texture asset.</summary>
/// <param name="assetName">The asset name to reload.</param>
/// <param name="language">The language for which to get assets.</param>
/// <param name="assetName">The asset name that changed.</param>
/// <param name="contentManagers">The content managers whose assets to update.</param>
/// <param name="ignoreWorld">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.</param>
/// <returns>Returns whether an asset was loaded.</returns>
[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")]
private bool PropagateTexture(IAssetName assetName, LocalizedContentManager.LanguageCode language, IList<IContentManager> contentManagers, bool ignoreWorld)
/// <returns>Returns whether any assets were updated.</returns>
private bool PropagateTexture(IAssetName assetName, IList<IContentManager> 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<Texture2D>(assetName)`,
// which internally maps to `contentManager.LoadLocalized<Texture2D>(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<Texture2D>[] newTextures = new Lazy<Texture2D>[assetNames.Length];
newTextures[0] = new Lazy<Texture2D>(() => this.DisposableContentManager.LoadLocalized<Texture2D>(assetName, language, useCache: false));
if (assetNames.Length > 1)
newTextures[1] = new Lazy<Texture2D>(() => this.DisposableContentManager.LoadLocalized<Texture2D>(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<Texture2D?> newTexture = new(() =>
{
if (this.DisposableContentManager.DoesAssetExist<Texture2D>(name))
return this.DisposableContentManager.LoadLocalized<Texture2D>(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<Texture2D>(name))
{
changed = true;
Texture2D texture = contentManager.LoadLocalized<Texture2D>(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<Texture2D>(name, currentLanguage, useCache: true);
texture.CopyFromTexture(newTexture.Value);
changed = true;
}
}
// drop temporary textures
foreach (Lazy<Texture2D> 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;
}
/// <summary>Reload one of the game's core assets (if applicable).</summary>
/// <param name="assetName">The asset name to reload.</param>
/// <param name="type">The asset type to reload.</param>
/// <summary>Propagate changes to an asset which isn't a map (handled by <see cref="PropagateMap"/>) or texture (handled by <see cref="PropagateTexture"/>).</summary>
/// <param name="assetName">The asset name that changed.</param>
/// <param name="ignoreWorld">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.</param>
/// <param name="changedWarpRoutes">Whether the locations reachable by warps from this location changed as part of this propagation.</param>
/// <returns>Returns whether an asset was loaded.</returns>
/// <returns>Returns whether any assets were updated.</returns>
[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<string> GetWarpSet(GameLocation location)
{
HashSet<string> 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<SpriteFont>(key);
Game1.dialogueFont = content.Load<SpriteFont>(baseName);
return true;
case "fonts/smallfont": // Game1.LoadContent
Game1.smallFont = content.Load<SpriteFont>(key);
Game1.smallFont = content.Load<SpriteFont>(baseName);
return true;
case "fonts/tinyfont": // Game1.LoadContent
Game1.tinyFont = content.Load<SpriteFont>(key);
Game1.tinyFont = content.Load<SpriteFont>(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
/// <param name="assetName">The asset name to update.</param>
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<bool>(location, "displayingSpouseRoom").SetValue(false);
location.MakeMapModifications(force: true);
// reset player position
@@ -808,10 +810,37 @@ internal class CoreAssetPropagator
});
}
/// <summary>Get the asset name for a farmhouse's spouse room, if it's currently displaying one.</summary>
/// <param name="farmhouse">The farmhouse whose spouse room to get.</param>
/// <param name="cache">A cache of spouse room map path lookups by farmhouse or cabin instance. This is created if it's null.</param>
private string? GetDisplayedSpouseRoomPath(FarmHouse farmhouse, ref Dictionary<FarmHouse, string?>? 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<bool>(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;
}
/// <summary>Get whether two asset names are equivalent if you ignore the locale code.</summary>
/// <param name="left">The first value to compare.</param>
/// <param name="right">The second value to compare.</param>
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
/// <summary>Get whether two asset names are equivalent if you ignore the locale code.</summary>
/// <param name="left">The first value to compare.</param>
/// <param name="right">The second value to compare.</param>
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;
+5 -1
View File
@@ -7,5 +7,9 @@ public enum PatchMode
Replace,
/// <summary>Draw the new content over the original content, so the original content shows through any transparent or semi-transparent pixels.</summary>
Overlay
Overlay,
/// <summary>Apply the new content over the original content as a transparency mask.</summary>
/// <remarks>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.</remarks>
Mask
}