Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2021-07-09 22:30:13 -04:00
commit 8f96a97f07
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
27 changed files with 589 additions and 254 deletions

View File

@ -1,7 +1,7 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!--set general build properties -->
<Version>3.10.1</Version>
<Version>3.11.0</Version>
<Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>

View File

@ -7,6 +7,30 @@
* Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info).
-->
## 3.11.0
Released 09 July 2021 for Stardew Valley 1.5.4 or later. See [release highlights](https://www.patreon.com/posts/53514295).
* For players:
* Updated for Stardew Valley 1.4.5 multiplayer hotfix on Linux/macOS.
* Fixed installer error on Windows when running as administrator (thanks to LostLogic!).
* Fixed installer error on some Windows systems (thanks to eddyballs!).
* Fixed error if SMAPI fails to dispose on game exit.
* Fixed `player_add` and `list_items` console commands not including some shirts _(in Console Commands)_.
* For mod authors:
* Added `World.FurnitureListChanged` event (thanks to DiscipleOfEris!).
* Added asset propagation for building/house paint masks.
* Added log message for troubleshooting if Windows software which often causes issues is installed (currently MSI Afterburner and RivaTuner).
* Improved validation for the manifest `Dependencies` field.
* Fixed validation for mods with invalid version `0.0.0`.
* Fixed _loaded with custom settings_ trace log added when using default settings.
* Fixed `Constants.SaveFolderName` and `Constants.CurrentSavePath` not set correctly in rare cases.
* For the web UI and JSON validator:
* Updated the JSON validator/schema for Content Patcher 1.23.0.
* Fixed [JSON schema](technical/web.md#using-a-schema-file-directly) in Visual Studio Code warning about comments and trailing commas.
* Fixed JSON schema for `i18n` files requiring the wrong value for the `$schema` field.
## 3.10.1
Released 03 May 2021 for Stardew Valley 1.5.4 or later.

View File

@ -324,7 +324,7 @@ To do that:
<GamePath>PATH_HERE</GamePath>
</PropertyGroup>
```
3. Replace `PATH_HERE` with your game's folder path.
3. Replace `PATH_HERE` with your game's folder path (don't add quotes).
The configuration will check your custom path first, then fall back to the default paths (so it'll
still compile on a different computer).

View File

@ -43,8 +43,34 @@ if [ "$UNAME" == "Darwin" ]; then
cp -p StardewValley.bin.osx StardewModdingAPI.bin.osx
fi
# Make sure we're running in Terminal (so the user can see errors/warnings/update alerts).
# Previously we would just use `open -a Terminal` to launch the .bin.osx file, but that
# doesn't let us set environment variables.
if [ ! -t 1 ]; then # https://stackoverflow.com/q/911168/262123
# sanity check to make sure we don't have an infinite loop of opening windows
SKIP_TERMINAL=false
for argument in "$@"; do
if [ "$argument" == "--no-reopen-terminal" ]; then
SKIP_TERMINAL=true
break
fi
done
# reopen in Terminal if needed
# https://stackoverflow.com/a/29511052/262123
if [ "$SKIP_TERMINAL" == "false" ]; then
echo "Reopening in the Terminal app..."
echo "\"$0\" $@ --no-reopen-terminal" > /tmp/open-smapi-terminal.sh
chmod +x /tmp/open-smapi-terminal.sh
cat /tmp/open-smapi-terminal.sh
open -W -a Terminal /tmp/open-smapi-terminal.sh
rm /tmp/open-smapi-terminal.sh
exit 0
fi
fi
# launch SMAPI
open -a Terminal ./StardewModdingAPI.bin.osx "$@"
LC_ALL="C" ./StardewModdingAPI.bin.osx "$@"
else
# choose binary file to launch
LAUNCH_FILE=""
@ -79,44 +105,44 @@ else
terminal|termite)
# consumes only one argument after -e
# options containing space characters are unsupported
exec $TERMINAL_NAME -e "env TERM=xterm $LAUNCH_FILE $@"
exec $TERMINAL_NAME -e "env TERM=xterm LC_ALL=\"C\" $LAUNCH_FILE $@"
;;
xterm|konsole|alacritty)
# consumes all arguments after -e
exec $TERMINAL_NAME -e env TERM=xterm $LAUNCH_FILE "$@"
exec $TERMINAL_NAME -e env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
;;
terminator|xfce4-terminal|mate-terminal)
# consumes all arguments after -x
exec $TERMINAL_NAME -x env TERM=xterm $LAUNCH_FILE "$@"
exec $TERMINAL_NAME -x env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
;;
gnome-terminal)
# consumes all arguments after --
exec $TERMINAL_NAME -- env TERM=xterm $LAUNCH_FILE "$@"
exec $TERMINAL_NAME -- env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
;;
kitty)
# consumes all trailing arguments
exec $TERMINAL_NAME env TERM=xterm $LAUNCH_FILE "$@"
exec $TERMINAL_NAME env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
;;
*)
# If we don't know the terminal, just try to run it in the current shell.
# If THAT fails, launch with no output.
env TERM=xterm $LAUNCH_FILE "$@"
env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
if [ $? -eq 127 ]; then
exec $LAUNCH_FILE --no-terminal "$@"
exec LC_ALL="C" $LAUNCH_FILE --no-terminal "$@"
fi
esac
## terminal isn't executable; fallback to current shell or no terminal
else
echo "The '$TERMINAL_NAME' terminal isn't executable. SMAPI might be running in a sandbox or the system might be misconfigured? Falling back to current shell."
env TERM=xterm $LAUNCH_FILE "$@"
env TERM=xterm LC_ALL="C" $LAUNCH_FILE "$@"
if [ $? -eq 127 ]; then
exec $LAUNCH_FILE --no-terminal "$@"
exec LC_ALL="C" $LAUNCH_FILE --no-terminal "$@"
fi
fi
fi

View File

@ -1,8 +1,8 @@
@echo off
echo %~dp0 | findstr /C:"%TEMP%" 1>nul
echo "%~dp0" | findstr /C:"%TEMP%" 1>nul
if not errorlevel 1 (
echo Oops! It looks like you're running the installer from inside a zip file. Make sure you unzip the download first.
pause
) else (
start /WAIT /B ./internal/windows-install.exe
start /WAIT /B internal\windows-install.exe
)

View File

@ -28,8 +28,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
** Public methods
*********/
/// <summary>Get all spawnable items.</summary>
/// <param name="itemTypes">The item types to fetch (or null for any type).</param>
/// <param name="includeVariants">Whether to include flavored variants like "Sunflower Honey".</param>
[SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")]
public IEnumerable<SearchableItem> GetAll()
public IEnumerable<SearchableItem> GetAll(ItemType[] itemTypes = null, bool includeVariants = true)
{
//
//
@ -41,10 +43,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
//
//
IEnumerable<SearchableItem> GetAllRaw()
{
HashSet<ItemType> types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null;
bool ShouldGet(ItemType type) => types == null || types.Contains(type);
// get tools
if (ShouldGet(ItemType.Tool))
{
for (int q = Tool.stone; q <= Tool.iridium; q++)
{
int quality = q;
@ -60,43 +66,44 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 1, _ => new Shears());
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, _ => new Pan());
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, _ => new Wand());
}
// clothing
if (ShouldGet(ItemType.Clothing))
{
// items
HashSet<int> clothingIds = new HashSet<int>();
foreach (int id in Game1.clothingInformation.Keys)
{
if (id < 0)
continue; // placeholder data for character customization clothing below
clothingIds.Add(id);
foreach (int id in this.GetShirtIds())
yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID));
}
// character customization shirts (some shirts in this range have no data, but game has special logic to handle them)
for (int id = 1000; id <= 1111; id++)
{
if (!clothingIds.Contains(id))
yield return this.TryCreate(ItemType.Clothing, id, p => new Clothing(p.ID));
}
}
// wallpapers
if (ShouldGet(ItemType.Wallpaper))
{
for (int id = 0; id < 112; id++)
yield return this.TryCreate(ItemType.Wallpaper, id, p => new Wallpaper(p.ID) { Category = SObject.furnitureCategory });
}
// flooring
if (ShouldGet(ItemType.Flooring))
{
for (int id = 0; id < 56; id++)
yield return this.TryCreate(ItemType.Flooring, id, p => new Wallpaper(p.ID, isFloor: true) { Category = SObject.furnitureCategory });
}
// equipment
if (ShouldGet(ItemType.Boots))
{
foreach (int id in this.TryLoad<int, string>("Data\\Boots").Keys)
yield return this.TryCreate(ItemType.Boots, id, p => new Boots(p.ID));
}
if (ShouldGet(ItemType.Hat))
{
foreach (int id in this.TryLoad<int, string>("Data\\hats").Keys)
yield return this.TryCreate(ItemType.Hat, id, p => new Hat(p.ID));
}
// weapons
if (ShouldGet(ItemType.Weapon))
{
foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys)
{
yield return this.TryCreate(ItemType.Weapon, id, p => (p.ID >= 32 && p.ID <= 34)
@ -104,22 +111,33 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
: new MeleeWeapon(p.ID)
);
}
}
// furniture
if (ShouldGet(ItemType.Furniture))
{
foreach (int id in this.TryLoad<int, string>("Data\\Furniture").Keys)
yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID));
}
// craftables
if (ShouldGet(ItemType.BigCraftable))
{
foreach (int id in Game1.bigCraftablesInformation.Keys)
yield return this.TryCreate(ItemType.BigCraftable, id, p => new SObject(Vector2.Zero, p.ID));
}
// objects
if (ShouldGet(ItemType.Object) || ShouldGet(ItemType.Ring))
{
foreach (int id in Game1.objectInformation.Keys)
{
string[] fields = Game1.objectInformation[id]?.Split('/');
// secret notes
if (id == 79)
{
if (ShouldGet(ItemType.Object))
{
foreach (int secretNoteId in this.TryLoad<int, string>("Data\\SecretNotes").Keys)
{
@ -131,13 +149,17 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
});
}
}
}
// ring
else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring
{
if (ShouldGet(ItemType.Ring))
yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID));
}
// item
else
else if (ShouldGet(ItemType.Object))
{
// spawn main item
SObject item = null;
@ -152,6 +174,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
continue;
// flavored items
if (includeVariants)
{
switch (item.Category)
{
// fruit products
@ -261,6 +285,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
}
}
}
}
}
return GetAllRaw().Where(p => p != null);
}
@ -333,5 +359,43 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
? new Color(61, 55, 42)
: (TailoringMenu.GetDyeColor(fish) ?? Color.Orange);
}
/// <summary>Get valid shirt IDs.</summary>
/// <remarks>
/// Shirts have a possible range of 10001999, but not all of those IDs are valid. There are two sets of IDs:
///
/// <list type="number">
/// <item>
/// Shirts which exist in <see cref="Game1.clothingInformation"/>.
/// </item>
/// <item>
/// Shirts with a dynamic ID and no entry in <see cref="Game1.clothingInformation"/>. These automatically
/// use the generic shirt entry with ID <c>-1</c> and are mapped to a calculated position in the
/// <c>Characters/Farmer/shirts</c> spritesheet. There's no constant we can use, but some known valid
/// ranges are 10001111 (used in <see cref="Farmer.changeShirt"/> for the customization screen and
/// 10001127 (used in <see cref="Utility.getShopStock"/> and <see cref="GameLocation.sandyShopStock"/>).
/// Based on the spritesheet, the max valid ID is 1299.
/// </item>
/// </list>
/// </remarks>
private IEnumerable<int> GetShirtIds()
{
// defined shirt items
foreach (int id in Game1.clothingInformation.Keys)
{
if (id < 0)
continue; // placeholder data for character customization clothing below
yield return id;
}
// dynamic shirts
HashSet<int> clothingIds = new HashSet<int>(Game1.clothingInformation.Keys);
for (int id = 1000; id <= 1299; id++)
{
if (!clothingIds.Contains(id))
yield return id;
}
}
}
}

View File

@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
"Version": "3.10.1",
"Version": "3.11.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.10.1"
"MinimumApiVersion": "3.11.0"
}

View File

@ -1,9 +1,9 @@
{
"Name": "Error Handler",
"Author": "SMAPI",
"Version": "3.10.1",
"Version": "3.11.0",
"Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
"UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.10.1"
"MinimumApiVersion": "3.11.0"
}

View File

@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
"Version": "3.10.1",
"Version": "3.11.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.10.1"
"MinimumApiVersion": "3.11.0"
}

View File

@ -66,7 +66,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
{
// update key
case ModDataFieldKey.UpdateKey:
return manifest.UpdateKeys != null && manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p));
return manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p));
// non-manifest fields
case ModDataFieldKey.StatusReasonPhrase:

View File

@ -69,7 +69,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
public IEnumerable<string> GetUpdateKeys(Manifest manifest)
{
return
(manifest.UpdateKeys ?? new string[0])
manifest.UpdateKeys
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray();
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialization.Converters;
@ -70,5 +71,14 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models
this.UpdateKeys = new string[0];
this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor };
}
/// <summary>Normalize the model after it's deserialized.</summary>
/// <param name="context">The deserialization context.</param>
[OnDeserialized]
public void OnDeserialized(StreamingContext context)
{
this.Dependencies ??= new IManifestDependency[0];
this.UpdateKeys ??= new string[0];
}
}
}

View File

@ -4,16 +4,19 @@
"title": "Content Patcher content pack",
"description": "Content Patcher content file for mods",
"@documentationUrl": "https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme",
"type": "object",
"allowComments": true,
"allowTrailingCommas": true,
"type": "object",
"properties": {
"Format": {
"title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
"type": "string",
"const": "1.22.0",
"const": "1.23.0",
"@errorMessages": {
"const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.22.0'."
"const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.23.0'."
}
},
"ConfigSchema": {

View File

@ -4,14 +4,17 @@
"title": "SMAPI i18n file",
"description": "A translation file for a SMAPI mod or content pack.",
"@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation",
"type": "object",
"allowComments": true,
"allowTrailingCommas": true,
"type": "object",
"properties": {
"$schema": {
"title": "Schema",
"description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.",
"type": "string",
"const": "https://smapi.io/schemas/manifest.json"
"const": "https://smapi.io/schemas/i18n.json"
}
},

View File

@ -4,6 +4,10 @@
"title": "SMAPI manifest",
"description": "Manifest file for a SMAPI mod or content pack",
"@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest",
"allowComments": true,
"allowTrailingCommas": true,
"type": "object",
"properties": {
"Name": {

View File

@ -61,7 +61,7 @@ namespace StardewModdingAPI
internal static int? LogScreenId { get; set; }
/// <summary>SMAPI's current raw semantic version.</summary>
internal static string RawApiVersion = "3.10.1";
internal static string RawApiVersion = "3.11.0";
}
/// <summary>Contains SMAPI's constants and assumptions.</summary>
@ -322,31 +322,42 @@ namespace StardewModdingAPI
/// <summary>Get the name of the save folder, if any.</summary>
private static string GetSaveFolderName()
{
// save not available
if (Context.LoadStage == LoadStage.None)
return null;
// get basic info
string saveName = Game1.GetSaveGameName(set_value: false);
ulong saveID = Context.LoadStage == LoadStage.SaveParsed
? SaveGame.loaded.uniqueIDForThisGame
: Game1.uniqueIDForThisGame;
// build folder name
return $"{new string(saveName.Where(char.IsLetterOrDigit).ToArray())}_{saveID}";
return Constants.GetSaveFolder()?.Name;
}
/// <summary>Get the path to the current save folder, if any.</summary>
private static string GetSaveFolderPathIfExists()
{
string folderName = Constants.GetSaveFolderName();
if (folderName == null)
return null;
string path = Path.Combine(Constants.SavesPath, folderName);
return Directory.Exists(path)
? path
DirectoryInfo saveFolder = Constants.GetSaveFolder();
return saveFolder?.Exists == true
? saveFolder.FullName
: null;
}
/// <summary>Get the current save folder, if any.</summary>
private static DirectoryInfo GetSaveFolder()
{
// save not available
if (Context.LoadStage == LoadStage.None)
return null;
// get basic info
string rawSaveName = Game1.GetSaveGameName(set_value: false);
ulong saveID = Context.LoadStage == LoadStage.SaveParsed
? SaveGame.loaded.uniqueIDForThisGame
: Game1.uniqueIDForThisGame;
// get best match (accounting for rare case where folder name isn't sanitized)
DirectoryInfo folder = null;
foreach (string saveName in new[] { rawSaveName, new string(rawSaveName.Where(char.IsLetterOrDigit).ToArray()) })
{
folder = new DirectoryInfo(Path.Combine(Constants.SavesPath, $"{saveName}_{saveID}"));
if (folder.Exists)
return folder;
}
// if save doesn't exist yet, return the default one we expect to be created
return folder;
}
}
}

View File

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StardewValley;
using StardewValley.Objects;
namespace StardewModdingAPI.Events
{
/// <summary>Event arguments for a <see cref="IWorldEvents.FurnitureListChanged"/> event.</summary>
public class FurnitureListChangedEventArgs : EventArgs
{
/*********
** Accessors
*********/
/// <summary>The location which changed.</summary>
public GameLocation Location { get; }
/// <summary>The furniture added to the location.</summary>
public IEnumerable<Furniture> Added { get; }
/// <summary>The furniture removed from the location.</summary>
public IEnumerable<Furniture> Removed { get; }
/// <summary>Whether this is the location containing the local player.</summary>
public bool IsCurrentLocation => object.ReferenceEquals(this.Location, Game1.player?.currentLocation);
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="location">The location which changed.</param>
/// <param name="added">The furniture added to the location.</param>
/// <param name="removed">The furniture removed from the location.</param>
internal FurnitureListChangedEventArgs(GameLocation location, IEnumerable<Furniture> added, IEnumerable<Furniture> removed)
{
this.Location = location;
this.Added = added.ToArray();
this.Removed = removed.ToArray();
}
}
}

View File

@ -28,5 +28,8 @@ namespace StardewModdingAPI.Events
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
/// <summary>Raised after furniture are added or removed in a location.</summary>
event EventHandler<FurnitureListChangedEventArgs> FurnitureListChanged;
}
}

View File

@ -162,6 +162,9 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
/// <summary>Raised after furniture are added or removed in a location.</summary>
public readonly ManagedEvent<FurnitureListChangedEventArgs> FurnitureListChanged;
/****
** Specialized
****/
@ -238,6 +241,7 @@ namespace StardewModdingAPI.Framework.Events
this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
this.ChestInventoryChanged = ManageEventOf<ChestInventoryChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged));
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
this.FurnitureListChanged = ManageEventOf<FurnitureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.FurnitureListChanged));
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true);

View File

@ -65,6 +65,13 @@ namespace StardewModdingAPI.Framework.Events
remove => this.EventManager.TerrainFeatureListChanged.Remove(value);
}
/// <summary>Raised after furniture are added or removed in a location.</summary>
public event EventHandler<FurnitureListChangedEventArgs> FurnitureListChanged
{
add => this.EventManager.FurnitureListChanged.Add(value, this.Mod);
remove => this.EventManager.FurnitureListChanged.Remove(value);
}
/*********
** Public methods

View File

@ -195,7 +195,10 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <inheritdoc />
public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false)
{
foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0])
if (!this.HasManifest())
yield break;
foreach (string rawKey in this.Manifest.UpdateKeys)
{
UpdateKey updateKey = UpdateKey.Parse(rawKey);
if (updateKey.LooksValid || !validOnly)
@ -251,16 +254,19 @@ namespace StardewModdingAPI.Framework.ModLoading
{
var ids = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
// yield dependencies
if (this.Manifest?.Dependencies != null)
if (this.HasManifest())
{
foreach (var entry in this.Manifest?.Dependencies)
// yield dependencies
foreach (IManifestDependency entry in this.Manifest.Dependencies)
{
if (!string.IsNullOrWhiteSpace(entry.UniqueID))
ids[entry.UniqueID] = entry.IsRequired;
}
// yield content pack parent
if (this.Manifest?.ContentPackFor?.UniqueID != null)
if (!string.IsNullOrWhiteSpace(this.Manifest.ContentPackFor?.UniqueID))
ids[this.Manifest.ContentPackFor.UniqueID] = true;
}
return ids;
}

View File

@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// get update URLs
List<string> updateUrls = new List<string>();
foreach (string key in mod.Manifest.UpdateKeys ?? new string[0])
foreach (string key in mod.Manifest.UpdateKeys)
{
string url = getUpdateUrl(key);
if (url != null)
@ -173,7 +173,7 @@ namespace StardewModdingAPI.Framework.ModLoading
if (string.IsNullOrWhiteSpace(mod.Manifest.Name))
missingFields.Add(nameof(IManifest.Name));
if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0")
if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0.0")
missingFields.Add(nameof(IManifest.Version));
if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID))
missingFields.Add(nameof(IManifest.UniqueID));
@ -188,6 +188,28 @@ namespace StardewModdingAPI.Framework.ModLoading
// validate ID format
if (!PathUtilities.IsSlug(mod.Manifest.UniqueID))
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
// validate dependencies
foreach (var dependency in mod.Manifest.Dependencies)
{
// null dependency
if (dependency == null)
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a null entry under {nameof(IManifest.Dependencies)}.");
continue;
}
// missing ID
if (string.IsNullOrWhiteSpace(dependency.UniqueID))
{
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field.");
continue;
}
// invalid ID
if (!PathUtilities.IsSlug(dependency.UniqueID))
mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
}
}
// validate IDs are unique

View File

@ -22,7 +22,7 @@ namespace StardewModdingAPI.Framework.Models
[nameof(VerboseLogging)] = false,
[nameof(LogNetworkTraffic)] = false,
[nameof(RewriteMods)] = true,
[nameof(AggressiveMemoryOptimizations)] = true
[nameof(AggressiveMemoryOptimizations)] = false
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>

View File

@ -11,6 +11,9 @@ using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
#if SMAPI_FOR_WINDOWS
using Microsoft.Win32;
#endif
using Microsoft.Xna.Framework;
#if SMAPI_FOR_XNA
using System.Windows.Forms;
@ -293,9 +296,16 @@ namespace StardewModdingAPI.Framework
this.LogManager.PressAnyKeyToExit();
}
finally
{
try
{
this.Dispose();
}
catch (Exception ex)
{
this.Monitor.Log($"The game ended, but SMAPI wasn't able to dispose correctly. Technical details: {ex}", LogLevel.Error);
}
}
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
@ -376,6 +386,9 @@ namespace StardewModdingAPI.Framework
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
// check for software likely to cause issues
this.CheckForSoftwareConflicts();
// check for updates
this.CheckForUpdatesAsync(mods);
}
@ -914,6 +927,10 @@ namespace StardewModdingAPI.Framework
// terrain features changed
if (locState.TerrainFeatures.IsChanged)
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
// furniture changed
if (locState.Furniture.IsChanged)
events.FurnitureListChanged.Raise(new FurnitureListChangedEventArgs(location, locState.Furniture.Added, locState.Furniture.Removed));
}
}
@ -1247,6 +1264,55 @@ namespace StardewModdingAPI.Framework
this.LogManager.SetConsoleTitle(consoleTitle);
}
/// <summary>Log a warning if software known to cause issues is installed.</summary>
private void CheckForSoftwareConflicts()
{
#if SMAPI_FOR_WINDOWS
this.Monitor.Log("Checking for known software conflicts...");
try
{
string[] registryKeys = { @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" };
string[] installedNames = registryKeys
.SelectMany(registryKey =>
{
using RegistryKey key = Registry.LocalMachine.OpenSubKey(registryKey);
if (key == null)
return new string[0];
return key
.GetSubKeyNames()
.Select(subkeyName =>
{
using RegistryKey subkey = key.OpenSubKey(subkeyName);
string displayName = (string)subkey?.GetValue("DisplayName");
string displayVersion = (string)subkey?.GetValue("DisplayVersion");
if (displayName != null && displayVersion != null && displayName.EndsWith($" {displayVersion}"))
displayName = displayName.Substring(0, displayName.Length - displayVersion.Length - 1);
return displayName;
})
.ToArray();
})
.Where(name => name != null && (name.Contains("MSI Afterburner") || name.Contains("RivaTuner")))
.Distinct()
.OrderBy(name => name)
.ToArray();
if (installedNames.Any())
this.Monitor.Log($" Found {string.Join(" and ", installedNames)} installed, which can conflict with SMAPI. If you experience errors or crashes, try disabling that software or adding an exception for SMAPI / Stardew Valley.");
else
this.Monitor.Log(" None found!");
}
catch (Exception ex)
{
this.Monitor.Log($"Failed when checking for conflicting software. Technical details:\n{ex}");
}
#endif
}
/// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary>
/// <param name="mods">The mods to include in the update check (if eligible).</param>
private void CheckForUpdatesAsync(IModMetadata[] mods)
@ -1593,8 +1659,6 @@ namespace StardewModdingAPI.Framework
// validate dependencies
// Although dependencies are validated before mods are loaded, a dependency may have failed to load.
if (mod.Manifest.Dependencies?.Any() == true)
{
foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired))
{
if (this.ModRegistry.Get(dependency.UniqueID) == null)
@ -1607,7 +1671,6 @@ namespace StardewModdingAPI.Framework
return false;
}
}
}
// load as content pack
if (mod.IsContentPack)

View File

@ -48,6 +48,9 @@ namespace StardewModdingAPI.Framework.StateTracking
/// <summary>Tracks added or removed terrain features.</summary>
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
/// <summary>Tracks added or removed furniture.</summary>
public ICollectionWatcher<Furniture> FurnitureWatcher { get; }
/// <summary>Tracks items added or removed to chests.</summary>
public IDictionary<Vector2, ChestTracker> ChestWatchers { get; } = new Dictionary<Vector2, ChestTracker>();
@ -68,6 +71,7 @@ namespace StardewModdingAPI.Framework.StateTracking
this.NpcsWatcher = WatcherFactory.ForNetCollection(location.characters);
this.ObjectsWatcher = WatcherFactory.ForNetDictionary(location.netObjects);
this.TerrainFeaturesWatcher = WatcherFactory.ForNetDictionary(location.terrainFeatures);
this.FurnitureWatcher = WatcherFactory.ForNetCollection(location.furniture);
this.Watchers.AddRange(new IWatcher[]
{
@ -76,7 +80,8 @@ namespace StardewModdingAPI.Framework.StateTracking
this.LargeTerrainFeaturesWatcher,
this.NpcsWatcher,
this.ObjectsWatcher,
this.TerrainFeaturesWatcher
this.TerrainFeaturesWatcher,
this.FurnitureWatcher
});
this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);

View File

@ -34,6 +34,9 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <summary>Tracks added or removed terrain features.</summary>
public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>();
/// <summary>Tracks added or removed furniture.</summary>
public SnapshotListDiff<Furniture> Furniture { get; } = new SnapshotListDiff<Furniture>();
/// <summary>Tracks changed chest inventories.</summary>
public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>();
@ -59,6 +62,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
this.Npcs.Update(watcher.NpcsWatcher);
this.Objects.Update(watcher.ObjectsWatcher);
this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher);
this.Furniture.Update(watcher.FurnitureWatcher);
// chest inventories
this.ChestItems.Clear();

View File

@ -233,6 +233,16 @@ namespace StardewModdingAPI.Metadata
return true;
}
case "buildings\\houses_paintmask": // Farm
{
bool removedFromCache = this.RemoveFromPaintMaskCache(key);
Farm farm = Game1.getFarm();
farm?.ApplyHousePaint();
return removedFromCache || farm != null;
}
/****
** Content\Characters\Farmer
****/
@ -613,7 +623,7 @@ namespace StardewModdingAPI.Metadata
return this.ReloadFarmAnimalSprites(content, key);
if (this.IsInFolder(key, "Buildings"))
return this.ReloadBuildings(content, key);
return this.ReloadBuildings(key);
if (this.KeyStartsWith(key, "LooseSprites\\Fence"))
return this.ReloadFenceTextures(key);
@ -717,28 +727,39 @@ namespace StardewModdingAPI.Metadata
}
/// <summary>Reload building textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
private bool ReloadBuildings(LocalizedContentManager content, string key)
private bool ReloadBuildings(string key)
{
// get buildings
// get paint mask info
const string paintMaskSuffix = "_PaintMask";
bool isPaintMask = key.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase);
// get building type
string type = Path.GetFileName(key);
if (isPaintMask)
type = type.Substring(0, type.Length - paintMaskSuffix.Length);
// get buildings
Building[] buildings = this.GetLocations(buildingInteriors: false)
.OfType<BuildableGameLocation>()
.SelectMany(p => p.buildings)
.Where(p => p.buildingType.Value == type)
.ToArray();
// reload buildings
// remove from paint mask cache
bool removedFromCache = this.RemoveFromPaintMaskCache(key);
// reload textures
if (buildings.Any())
{
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Building building in buildings)
building.texture = texture;
building.resetTexture();
return true;
}
return false;
return removedFromCache;
}
/// <summary>Reload map seat textures.</summary>
@ -1295,5 +1316,18 @@ namespace StardewModdingAPI.Metadata
// else just (re)load it from the main content manager
return this.MainContentManager.Load<Texture2D>(key);
}
/// <summary>Remove a case-insensitive key from the paint mask cache.</summary>
/// <param name="key">The paint mask asset key.</param>
private bool RemoveFromPaintMaskCache(string key)
{
// make cache case-insensitive
// This is needed for cache invalidation since mods may specify keys with a different capitalization
if (!object.ReferenceEquals(BuildingPainter.paintMaskLookup.Comparer, StringComparer.OrdinalIgnoreCase))
BuildingPainter.paintMaskLookup = new Dictionary<string, List<List<int>>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase);
// remove key from cache
return BuildingPainter.paintMaskLookup.Remove(key);
}
}
}