summaryrefslogtreecommitdiff
path: root/src/SMAPI
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI')
-rw-r--r--src/SMAPI/Constants.cs2
-rw-r--r--src/SMAPI/Events/ChestInventoryChangedEventArgs.cs48
-rw-r--r--src/SMAPI/Events/IWorldEvents.cs3
-rw-r--r--src/SMAPI/Events/InventoryChangedEventArgs.cs34
-rw-r--r--src/SMAPI/Events/ItemStackChange.cs20
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetInterceptorChange.cs93
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs5
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs107
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs38
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs49
-rw-r--r--src/SMAPI/Framework/ContentManagers/IContentManager.cs4
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs30
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs4
-rw-r--r--src/SMAPI/Framework/Events/ModWorldEvents.cs7
-rw-r--r--src/SMAPI/Framework/IModMetadata.cs4
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs3
-rw-r--r--src/SMAPI/Framework/ModHelpers/DataHelper.cs48
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs5
-rw-r--r--src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs3
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs21
-rw-r--r--src/SMAPI/Framework/Models/ModFolderExport.cs21
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs6
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedMethod.cs4
-rw-r--r--src/SMAPI/Framework/SCore.cs154
-rw-r--r--src/SMAPI/Framework/SGame.cs75
-rw-r--r--src/SMAPI/Framework/SnapshotItemListDiff.cs66
-rw-r--r--src/SMAPI/Framework/StateTracking/ChestTracker.cs101
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs143
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs28
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs8
-rw-r--r--src/SMAPI/Framework/StateTracking/LocationTracker.cs60
-rw-r--r--src/SMAPI/Framework/StateTracking/PlayerTracker.cs32
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs13
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs15
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs144
-rw-r--r--src/SMAPI/Metadata/InstructionMetadata.cs1
-rw-r--r--src/SMAPI/SMAPI.config.json6
-rw-r--r--src/SMAPI/SMAPI.csproj21
-rw-r--r--src/SMAPI/i18n/es.json3
-rw-r--r--src/SMAPI/i18n/ja.json3
-rw-r--r--src/SMAPI/i18n/pt.json3
-rw-r--r--src/SMAPI/i18n/zh.json6
43 files changed, 1078 insertions, 367 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 7fdfb8d0..97204d86 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -20,7 +20,7 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.0.1");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0");
diff --git a/src/SMAPI/Events/ChestInventoryChangedEventArgs.cs b/src/SMAPI/Events/ChestInventoryChangedEventArgs.cs
new file mode 100644
index 00000000..4b4c4210
--- /dev/null
+++ b/src/SMAPI/Events/ChestInventoryChangedEventArgs.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using StardewValley;
+using StardewValley.Objects;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for a <see cref="IWorldEvents.ChestInventoryChanged"/> event.</summary>
+ public class ChestInventoryChangedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The chest whose inventory changed.</summary>
+ public Chest Chest { get; }
+
+ /// <summary>The location containing the chest.</summary>
+ public GameLocation Location { get; }
+
+ /// <summary>The added item stacks.</summary>
+ public IEnumerable<Item> Added { get; }
+
+ /// <summary>The removed item stacks.</summary>
+ public IEnumerable<Item> Removed { get; }
+
+ /// <summary>The item stacks whose size changed.</summary>
+ public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="chest">The chest whose inventory changed.</param>
+ /// <param name="location">The location containing the chest.</param>
+ /// <param name="added">The added item stacks.</param>
+ /// <param name="removed">The removed item stacks.</param>
+ /// <param name="quantityChanged">The item stacks whose size changed.</param>
+ internal ChestInventoryChangedEventArgs(Chest chest, GameLocation location, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
+ {
+ this.Location = location;
+ this.Chest = chest;
+ this.Added = added;
+ this.Removed = removed;
+ this.QuantityChanged = quantityChanged;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/IWorldEvents.cs b/src/SMAPI/Events/IWorldEvents.cs
index 0ceffcc1..9569a57b 100644
--- a/src/SMAPI/Events/IWorldEvents.cs
+++ b/src/SMAPI/Events/IWorldEvents.cs
@@ -23,6 +23,9 @@ namespace StardewModdingAPI.Events
/// <summary>Raised after objects are added or removed in a location.</summary>
event EventHandler<ObjectListChangedEventArgs> ObjectListChanged;
+ /// <summary>Raised after items are added or removed from a chest.</summary>
+ event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged;
+
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
}
diff --git a/src/SMAPI/Events/InventoryChangedEventArgs.cs b/src/SMAPI/Events/InventoryChangedEventArgs.cs
index 874c2e48..40cd4128 100644
--- a/src/SMAPI/Events/InventoryChangedEventArgs.cs
+++ b/src/SMAPI/Events/InventoryChangedEventArgs.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Events
@@ -14,13 +13,13 @@ namespace StardewModdingAPI.Events
/// <summary>The player whose inventory changed.</summary>
public Farmer Player { get; }
- /// <summary>The added items.</summary>
+ /// <summary>The added item stacks.</summary>
public IEnumerable<Item> Added { get; }
- /// <summary>The removed items.</summary>
+ /// <summary>The removed item stacks.</summary>
public IEnumerable<Item> Removed { get; }
- /// <summary>The items whose stack sizes changed, with the relative change.</summary>
+ /// <summary>The item stacks whose size changed.</summary>
public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
/// <summary>Whether the affected player is the local one.</summary>
@@ -32,28 +31,15 @@ namespace StardewModdingAPI.Events
*********/
/// <summary>Construct an instance.</summary>
/// <param name="player">The player whose inventory changed.</param>
- /// <param name="changedItems">The inventory changes.</param>
- internal InventoryChangedEventArgs(Farmer player, ItemStackChange[] changedItems)
+ /// <param name="added">The added item stacks.</param>
+ /// <param name="removed">The removed item stacks.</param>
+ /// <param name="quantityChanged">The item stacks whose size changed.</param>
+ internal InventoryChangedEventArgs(Farmer player, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
{
this.Player = player;
- this.Added = changedItems
- .Where(n => n.ChangeType == ChangeType.Added)
- .Select(p => p.Item)
- .ToArray();
-
- this.Removed = changedItems
- .Where(n => n.ChangeType == ChangeType.Removed)
- .Select(p => p.Item)
- .ToArray();
-
- this.QuantityChanged = changedItems
- .Where(n => n.ChangeType == ChangeType.StackChange)
- .Select(change => new ItemStackSizeChange(
- item: change.Item,
- oldSize: change.Item.Stack - change.StackChange,
- newSize: change.Item.Stack
- ))
- .ToArray();
+ this.Added = added;
+ this.Removed = removed;
+ this.QuantityChanged = quantityChanged;
}
}
}
diff --git a/src/SMAPI/Events/ItemStackChange.cs b/src/SMAPI/Events/ItemStackChange.cs
deleted file mode 100644
index f9ae6df6..00000000
--- a/src/SMAPI/Events/ItemStackChange.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using StardewValley;
-
-namespace StardewModdingAPI.Events
-{
- /// <summary>Represents an inventory slot that changed.</summary>
- public class ItemStackChange
- {
- /*********
- ** Accessors
- *********/
- /// <summary>The item in the slot.</summary>
- public Item Item { get; set; }
-
- /// <summary>The amount by which the item's stack size changed.</summary>
- public int StackChange { get; set; }
-
- /// <summary>How the inventory slot changed.</summary>
- public ChangeType ChangeType { get; set; }
- }
-} \ No newline at end of file
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 4ae2ad68..aa615a0b 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -42,8 +42,8 @@ namespace StardewModdingAPI.Framework.Content
Texture2D target = this.Data;
// get areas
- sourceArea = sourceArea ?? new Rectangle(0, 0, source.Width, source.Height);
- targetArea = targetArea ?? new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
+ sourceArea ??= new Rectangle(0, 0, source.Width, source.Height);
+ targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
// validate
if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)
diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
new file mode 100644
index 00000000..037d9f89
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Reflection;
+
+namespace StardewModdingAPI.Framework.Content
+{
+ /// <summary>A wrapper for <see cref="IAssetEditor"/> and <see cref="IAssetLoader"/> for internal cache invalidation.</summary>
+ internal class AssetInterceptorChange
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod which registered the interceptor.</summary>
+ public IModMetadata Mod { get; }
+
+ /// <summary>The interceptor instance.</summary>
+ public object Instance { get; }
+
+ /// <summary>Whether the asset interceptor was added since the last tick. Mutually exclusive with <see cref="WasRemoved"/>.</summary>
+ public bool WasAdded { get; }
+
+ /// <summary>Whether the asset interceptor was removed since the last tick. Mutually exclusive with <see cref="WasRemoved"/>.</summary>
+ public bool WasRemoved => this.WasAdded;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod registering the interceptor.</param>
+ /// <param name="instance">The interceptor. This must be an <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> instance.</param>
+ /// <param name="wasAdded">Whether the asset interceptor was added since the last tick; else removed.</param>
+ public AssetInterceptorChange(IModMetadata mod, object instance, bool wasAdded)
+ {
+ this.Mod = mod ?? throw new ArgumentNullException(nameof(mod));
+ this.Instance = instance ?? throw new ArgumentNullException(nameof(instance));
+ this.WasAdded = wasAdded;
+
+ if (!(instance is IAssetEditor) && !(instance is IAssetLoader))
+ throw new InvalidCastException($"The provided {nameof(instance)} value must be an {nameof(IAssetEditor)} or {nameof(IAssetLoader)} instance.");
+ }
+
+ /// <summary>Get whether this instance can intercept the given asset.</summary>
+ /// <param name="asset">Basic metadata about the asset being loaded.</param>
+ public bool CanIntercept(IAssetInfo asset)
+ {
+ MethodInfo canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic);
+ if (canIntercept == null)
+ throw new InvalidOperationException($"SMAPI couldn't access the {nameof(AssetInterceptorChange)}.{nameof(this.CanInterceptImpl)} implementation.");
+
+ return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset });
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether this instance can intercept the given asset.</summary>
+ /// <typeparam name="TAsset">The asset type.</typeparam>
+ /// <param name="asset">Basic metadata about the asset being loaded.</param>
+ private bool CanInterceptImpl<TAsset>(IAssetInfo asset)
+ {
+ // check edit
+ if (this.Instance is IAssetEditor editor)
+ {
+ try
+ {
+ if (editor.CanEdit<TAsset>(asset))
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ // check load
+ if (this.Instance is IAssetLoader loader)
+ {
+ try
+ {
+ if (loader.CanLoad<TAsset>(asset))
+ return true;
+ }
+ catch (Exception ex)
+ {
+ this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index 4178b663..f33ff84d 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -119,13 +119,12 @@ namespace StardewModdingAPI.Framework.Content
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns the removed keys (if any).</returns>
- public IEnumerable<string> Remove(Func<string, Type, bool> predicate, bool dispose = false)
+ public IEnumerable<string> Remove(Func<string, object, bool> predicate, bool dispose)
{
List<string> removed = new List<string>();
foreach (string key in this.Cache.Keys.ToArray())
{
- Type type = this.Cache[key].GetType();
- if (predicate(key, type))
+ if (predicate(key, this.Cache[key]))
{
this.Remove(key, dispose);
removed.Add(key);
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 08ebe6a5..82d3805b 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Reflection;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
@@ -188,59 +188,6 @@ namespace StardewModdingAPI.Framework
return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false);
}
- /// <summary>Purge assets from the cache that match one of the interceptors.</summary>
- /// <param name="editors">The asset editors for which to purge matching assets.</param>
- /// <param name="loaders">The asset loaders for which to purge matching assets.</param>
- /// <returns>Returns the invalidated asset names.</returns>
- public IEnumerable<string> InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
- {
- if (!editors.Any() && !loaders.Any())
- return new string[0];
-
- // get CanEdit/Load methods
- MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
- MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
- if (canEdit == null || canLoad == null)
- throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
-
- // invalidate matching keys
- return this.InvalidateCache(asset =>
- {
- // check loaders
- MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
- foreach (IAssetLoader loader in loaders)
- {
- try
- {
- if ((bool)canLoadGeneric.Invoke(loader, new object[] { asset }))
- return true;
- }
- catch (Exception ex)
- {
- this.GetModFor(loader).LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
- }
- }
-
- // check editors
- MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
- foreach (IAssetEditor editor in editors)
- {
- try
- {
- if ((bool)canEditGeneric.Invoke(editor, new object[] { asset }))
- return true;
- }
- catch (Exception ex)
- {
- this.GetModFor(editor).LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
- }
- }
-
- // asset not affected by a loader or editor
- return false;
- });
- }
-
/// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
@@ -261,24 +208,28 @@ namespace StardewModdingAPI.Framework
/// <returns>Returns the invalidated asset names.</returns>
public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{
- // invalidate cache
- IDictionary<string, Type> removedAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase);
+ // invalidate cache & track removed assets
+ IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
foreach (IContentManager contentManager in this.ContentManagers)
{
- foreach (Tuple<string, Type> asset in contentManager.InvalidateCache(predicate, dispose))
- removedAssetNames[asset.Item1] = asset.Item2;
+ foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
+ {
+ if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
+ removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
+ assets.Add(entry.Value);
+ }
}
// reload core game assets
- int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager
-
- // report result
- if (removedAssetNames.Any())
- this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
+ if (removedAssets.Any())
+ {
+ IDictionary<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager
+ this.Monitor.Log($"Invalidated {removedAssets.Count} asset names ({string.Join(", ", removedAssets.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}); propagated {propagated.Count(p => p.Value)} core assets.", LogLevel.Trace);
+ }
else
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
- return removedAssetNames.Keys;
+ return removedAssets.Keys;
}
/// <summary>Dispose held resources.</summary>
@@ -308,33 +259,5 @@ namespace StardewModdingAPI.Framework
this.ContentManagers.Remove(contentManager);
}
-
- /// <summary>Get the mod which registered an asset loader.</summary>
- /// <param name="loader">The asset loader.</param>
- /// <exception cref="KeyNotFoundException">The given loader couldn't be matched to a mod.</exception>
- private IModMetadata GetModFor(IAssetLoader loader)
- {
- foreach (var pair in this.Loaders)
- {
- if (pair.Value.Contains(loader))
- return pair.Key;
- }
-
- throw new KeyNotFoundException("This loader isn't associated with a known mod.");
- }
-
- /// <summary>Get the mod which registered an asset editor.</summary>
- /// <param name="editor">The asset editor.</param>
- /// <exception cref="KeyNotFoundException">The given editor couldn't be matched to a mod.</exception>
- private IModMetadata GetModFor(IAssetEditor editor)
- {
- foreach (var pair in this.Editors)
- {
- if (pair.Value.Contains(editor))
- return pair.Key;
- }
-
- throw new KeyNotFoundException("This editor isn't associated with a known mod.");
- }
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
index 5283340e..36f2f650 100644
--- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -41,6 +41,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>A list of disposable assets.</summary>
private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>();
+ /// <summary>The disposable assets tracked by the base content manager.</summary>
+ /// <remarks>This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by <see cref="Disposables"/> instead, which avoids a hard reference.</remarks>
+ private readonly List<IDisposable> BaseDisposableReferences;
+
/*********
** Accessors
@@ -84,6 +88,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get asset data
this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
+ this.BaseDisposableReferences = reflection.GetField<List<IDisposable>>(this, "disposableAssets").GetValue();
}
/// <summary>Load an asset that has been processed by the content pipeline.</summary>
@@ -184,25 +189,25 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
- /// <returns>Returns the invalidated asset names and types.</returns>
- public IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
+ /// <returns>Returns the invalidated asset names and instances.</returns>
+ public IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{
- Dictionary<string, Type> removeAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase);
- this.Cache.Remove((key, type) =>
+ IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.InvariantCultureIgnoreCase);
+ this.Cache.Remove((key, asset) =>
{
this.ParseCacheKey(key, out string assetName, out _);
- if (removeAssetNames.ContainsKey(assetName))
+ if (removeAssets.ContainsKey(assetName))
return true;
- if (predicate(assetName, type))
+ if (predicate(assetName, asset.GetType()))
{
- removeAssetNames[assetName] = type;
+ removeAssets[assetName] = asset;
return true;
}
return false;
- });
+ }, dispose);
- return removeAssetNames.Select(p => Tuple.Create(p.Key, p.Value));
+ return removeAssets;
}
/// <summary>Dispose held resources.</summary>
@@ -258,20 +263,27 @@ namespace StardewModdingAPI.Framework.ContentManagers
: base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
}
- /// <summary>Inject an asset into the cache.</summary>
+ /// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param>
/// <param name="language">The language code for which to inject the asset.</param>
- protected virtual void Inject<T>(string assetName, T value, LanguageCode language)
+ /// <param name="useCache">Whether to save the asset to the asset cache.</param>
+ protected virtual void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
{
// track asset key
if (value is Texture2D texture)
texture.Name = assetName;
// cache asset
- assetName = this.AssertAndNormalizeAssetName(assetName);
- this.Cache[assetName] = value;
+ if (useCache)
+ {
+ assetName = this.AssertAndNormalizeAssetName(assetName);
+ this.Cache[assetName] = value;
+ }
+
+ // avoid hard disposable references; see remarks on the field
+ this.BaseDisposableReferences.Clear();
}
/// <summary>Parse a cache key into its component parts.</summary>
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index 0b563555..8930267d 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -83,8 +83,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
{
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
- if (useCache)
- this.Inject(assetName, managedAsset, language);
+ this.TrackAsset(assetName, managedAsset, language, useCache);
return managedAsset;
}
@@ -111,7 +110,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
// update cache & return data
- this.Inject(assetName, data, language);
+ this.TrackAsset(assetName, data, language, useCache);
return data;
}
@@ -131,7 +130,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
removeAssetNames.Contains(key)
|| (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
)
- .Select(p => p.Item1)
+ .Select(p => p.Key)
.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase)
.ToArray();
if (invalidated.Any())
@@ -169,18 +168,19 @@ namespace StardewModdingAPI.Framework.ContentManagers
return false;
}
- /// <summary>Inject an asset into the cache.</summary>
+ /// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param>
/// <param name="language">The language code for which to inject the asset.</param>
- protected override void Inject<T>(string assetName, T value, LanguageCode language)
+ /// <param name="useCache">Whether to save the asset to the asset cache.</param>
+ protected override void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
{
// handle explicit language in asset name
{
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
{
- this.Inject(newAssetName, value, newLanguage);
+ this.TrackAsset(newAssetName, value, newLanguage, useCache);
return;
}
}
@@ -192,24 +192,27 @@ namespace StardewModdingAPI.Framework.ContentManagers
// only caches by the most specific key).
// 2. Because a mod asset loader/editor may have changed the asset in a way that
// doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`.
- string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
- base.Inject(assetName, value, language);
- if (this.Cache.ContainsKey(keyWithLocale))
- base.Inject(keyWithLocale, value, language);
-
- // track whether the injected asset is translatable for is-loaded lookups
- if (this.Cache.ContainsKey(keyWithLocale))
- {
- this.IsLocalizableLookup[assetName] = true;
- this.IsLocalizableLookup[keyWithLocale] = true;
- }
- else if (this.Cache.ContainsKey(assetName))
+ if (useCache)
{
- this.IsLocalizableLookup[assetName] = false;
- this.IsLocalizableLookup[keyWithLocale] = false;
+ string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
+ base.TrackAsset(assetName, value, language, useCache: true);
+ if (this.Cache.ContainsKey(keyWithLocale))
+ base.TrackAsset(keyWithLocale, value, language, useCache: true);
+
+ // track whether the injected asset is translatable for is-loaded lookups
+ if (this.Cache.ContainsKey(keyWithLocale))
+ {
+ this.IsLocalizableLookup[assetName] = true;
+ this.IsLocalizableLookup[keyWithLocale] = true;
+ }
+ else if (this.Cache.ContainsKey(assetName))
+ {
+ this.IsLocalizableLookup[assetName] = false;
+ this.IsLocalizableLookup[keyWithLocale] = false;
+ }
+ else
+ this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
}
- else
- this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
}
/// <summary>Load an asset file directly from the underlying content manager.</summary>
diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
index 12c01352..8da9a777 100644
--- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
@@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
- /// <returns>Returns the invalidated asset names and types.</returns>
- IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
+ /// <returns>Returns the invalidated asset names and instances.</returns>
+ IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 90b86179..fdf76b24 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -105,6 +105,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get local asset
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}");
+ T asset;
try
{
// get file
@@ -118,22 +119,22 @@ namespace StardewModdingAPI.Framework.ContentManagers
// XNB file
case ".xnb":
{
- T data = this.RawLoad<T>(assetName, useCache: false);
- if (data is Map map)
+ asset = this.RawLoad<T>(assetName, useCache: false);
+ if (asset is Map map)
{
this.NormalizeTilesheetPaths(map);
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
}
- return data;
}
+ break;
// unpacked data
case ".json":
{
- if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data))
+ if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out asset))
throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above
- return data;
}
+ break;
// unpacked image
case ".png":
@@ -143,13 +144,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
// fetch & cache
- using (FileStream stream = File.OpenRead(file.FullName))
- {
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
- return (T)(object)texture;
- }
+ using FileStream stream = File.OpenRead(file.FullName);
+
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
+ texture = this.PremultiplyTransparency(texture);
+ asset = (T)(object)texture;
}
+ break;
// unpacked map
case ".tbin":
@@ -163,8 +164,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
Map map = formatManager.LoadMap(file.FullName);
this.NormalizeTilesheetPaths(map);
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
- return (T)(object)map;
+ asset = (T)(object)map;
}
+ break;
default:
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'.");
@@ -176,6 +178,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex);
}
+
+ // track & return asset
+ this.TrackAsset(assetName, asset, language, useCache);
+ return asset;
}
/// <summary>Create a new content manager for temporary use.</summary>
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index 18b00f69..892cbc7b 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -148,6 +148,9 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Raised after objects are added or removed in a location.</summary>
public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged;
+ /// <summary>Raised after items are added or removed from a chest.</summary>
+ public readonly ManagedEvent<ChestInventoryChangedEventArgs> ChestInventoryChanged;
+
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
@@ -221,6 +224,7 @@ namespace StardewModdingAPI.Framework.Events
this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged));
this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged));
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.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs
index b85002a3..2ae69669 100644
--- a/src/SMAPI/Framework/Events/ModWorldEvents.cs
+++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs
@@ -51,6 +51,13 @@ namespace StardewModdingAPI.Framework.Events
remove => this.EventManager.ObjectListChanged.Remove(value);
}
+ /// <summary>Raised after items are added or removed from a chest.</summary>
+ public event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged
+ {
+ add => this.EventManager.ChestInventoryChanged.Add(value);
+ remove => this.EventManager.ChestInventoryChanged.Remove(value);
+ }
+
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
{
diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs
index 6ee7df69..37927482 100644
--- a/src/SMAPI/Framework/IModMetadata.cs
+++ b/src/SMAPI/Framework/IModMetadata.cs
@@ -105,6 +105,10 @@ namespace StardewModdingAPI.Framework
/// <param name="validOnly">Only return valid update keys.</param>
IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true);
+ /// <summary>Get the mod IDs that must be installed to load this mod.</summary>
+ /// <param name="includeOptional">Whether to include optional dependencies.</param>
+ IEnumerable<string> GetRequiredModIds(bool includeOptional = false);
+
/// <summary>Whether the mod has at least one valid update key set.</summary>
bool HasValidUpdateKeys();
diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs
index d69e5604..84cea36c 100644
--- a/src/SMAPI/Framework/Input/SInputState.cs
+++ b/src/SMAPI/Framework/Input/SInputState.cs
@@ -129,6 +129,9 @@ namespace StardewModdingAPI.Framework.Input
[Obsolete("This method should only be called by the game itself.")]
public override GamePadState GetGamePadState()
{
+ if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff)
+ return base.GetGamePadState();
+
return this.ShouldSuppressNow()
? this.SuppressedController
: this.RealController;
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index cc08c42b..3d43c539 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
+using StardewModdingAPI.Enums;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
@@ -77,33 +79,45 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
public TModel ReadSaveData<TModel>(string key) where TModel : class
{
- if (!Game1.hasLoadedGame)
+ if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded.");
if (!Game1.IsMasterGame)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
- return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value)
- ? this.JsonHelper.Deserialize<TModel>(value)
- : null;
+
+ string internalKey = this.GetSaveFileKey(key);
+ foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
+ {
+ if (dataField.TryGetValue(internalKey, out string value))
+ return this.JsonHelper.Deserialize<TModel>(value);
+ }
+ return null;
}
/// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary>
/// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam>
/// <param name="key">The unique key identifying the data.</param>
- /// <param name="data">The arbitrary data to save.</param>
+ /// <param name="model">The arbitrary data to save.</param>
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
- public void WriteSaveData<TModel>(string key, TModel data) where TModel : class
+ public void WriteSaveData<TModel>(string key, TModel model) where TModel : class
{
- if (!Game1.hasLoadedGame)
+ if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded.");
if (!Game1.IsMasterGame)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
string internalKey = this.GetSaveFileKey(key);
- if (data != null)
- Game1.CustomData[internalKey] = this.JsonHelper.Serialize(data, Formatting.None);
- else
- Game1.CustomData.Remove(internalKey);
+ string data = model != null
+ ? this.JsonHelper.Serialize(model, Formatting.None)
+ : null;
+
+ foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
+ {
+ if (data != null)
+ dataField[internalKey] = data;
+ else
+ dataField.Remove(internalKey);
+ }
}
/****
@@ -146,6 +160,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
return $"smapi/mod-data/{this.ModID}/{key}".ToLower();
}
+ /// <summary>Get the data fields to read/write for save data.</summary>
+ /// <param name="stage">The current load stage.</param>
+ private IEnumerable<IDictionary<string, string>> GetDataFields(LoadStage stage)
+ {
+ if (stage == LoadStage.None)
+ yield break;
+
+ yield return Game1.CustomData;
+ if (SaveGame.loaded != null)
+ yield return SaveGame.loaded.CustomData;
+ }
+
/// <summary>Get the absolute path for a global data file.</summary>
/// <param name="key">The unique key identifying the data.</param>
private string GetGlobalDataPath(string key)
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index 7670eb3a..b5533335 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -356,6 +356,11 @@ namespace StardewModdingAPI.Framework.ModLoading
mod.SetWarning(ModWarning.UsesDynamic);
break;
+ case InstructionHandleResult.DetectedConsoleAccess:
+ this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected direct console access ({handler.NounPhrase}) in assembly {filename}.");
+ mod.SetWarning(ModWarning.AccessesConsole);
+ break;
+
case InstructionHandleResult.DetectedFilesystemAccess:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}.");
mod.SetWarning(ModWarning.AccessesFilesystem);
diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
index d93b603d..a948213b 100644
--- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
+++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
@@ -26,6 +26,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary>
DetectedUnvalidatedUpdateTick,
+ /// <summary>The instruction accesses the SMAPI console directly.</summary>
+ DetectedConsoleAccess,
+
/// <summary>The instruction accesses the filesystem directly.</summary>
DetectedFilesystemAccess,
diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
index 7f788d17..0e90362e 100644
--- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs
+++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
@@ -188,6 +188,27 @@ namespace StardewModdingAPI.Framework.ModLoading
}
}
+ /// <summary>Get the mod IDs that must be installed to load this mod.</summary>
+ /// <param name="includeOptional">Whether to include optional dependencies.</param>
+ public IEnumerable<string> GetRequiredModIds(bool includeOptional = false)
+ {
+ HashSet<string> required = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+
+ // yield dependencies
+ if (this.Manifest?.Dependencies != null)
+ {
+ foreach (var entry in this.Manifest?.Dependencies)
+ {
+ if ((entry.IsRequired || includeOptional) && required.Add(entry.UniqueID))
+ yield return entry.UniqueID;
+ }
+ }
+
+ // yield content pack parent
+ if (this.Manifest?.ContentPackFor?.UniqueID != null && required.Add(this.Manifest.ContentPackFor.UniqueID))
+ yield return this.Manifest.ContentPackFor.UniqueID;
+ }
+
/// <summary>Whether the mod has at least one valid update key set.</summary>
public bool HasValidUpdateKeys()
{
diff --git a/src/SMAPI/Framework/Models/ModFolderExport.cs b/src/SMAPI/Framework/Models/ModFolderExport.cs
deleted file mode 100644
index 3b8d451a..00000000
--- a/src/SMAPI/Framework/Models/ModFolderExport.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace StardewModdingAPI.Framework.Models
-{
- /// <summary>Metadata exported to the mod folder.</summary>
- internal class ModFolderExport
- {
- /// <summary>When the export was generated.</summary>
- public string Exported { get; set; }
-
- /// <summary>The absolute path of the mod folder.</summary>
- public string ModFolderPath { get; set; }
-
- /// <summary>The game version which last loaded the mods.</summary>
- public string GameVersion { get; set; }
-
- /// <summary>The SMAPI version which last loaded the mods.</summary>
- public string ApiVersion { get; set; }
-
- /// <summary>The detected mods.</summary>
- public IModMetadata[] Mods { get; set; }
- }
-}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 53939f8c..b1612aa4 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -25,8 +25,7 @@ namespace StardewModdingAPI.Framework.Models
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(VerboseLogging)] = false,
- [nameof(LogNetworkTraffic)] = false,
- [nameof(DumpMetadata)] = false
+ [nameof(LogNetworkTraffic)] = false
};
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
@@ -64,9 +63,6 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
public bool LogNetworkTraffic { get; set; }
- /// <summary>Whether to generate a file in the mods folder with detailed metadata about the detected mods.</summary>
- public bool DumpMetadata { get; set; }
-
/// <summary>The colors to use for text written to the SMAPI console.</summary>
public ColorSchemeConfig ConsoleColors { get; set; }
diff --git a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
index 039f27c3..82737a7f 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
@@ -65,6 +65,10 @@ namespace StardewModdingAPI.Framework.Reflection
{
result = this.MethodInfo.Invoke(this.Parent, arguments);
}
+ catch (TargetParameterCountException)
+ {
+ throw new Exception($"Couldn't invoke the {this.DisplayName} method: it expects {this.MethodInfo.GetParameters().Length} parameters, but {arguments.Length} were provided.");
+ }
catch (Exception ex)
{
throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex);
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index f1873391..dfd77e16 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -97,16 +97,25 @@ namespace StardewModdingAPI.Framework
};
/// <summary>Regex patterns which match console messages to show a more friendly error for.</summary>
- private readonly Tuple<Regex, string, LogLevel>[] ReplaceConsolePatterns =
+ private readonly ReplaceLogPattern[] ReplaceConsolePatterns =
{
- Tuple.Create(
- new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ // Steam not loaded
+ new ReplaceLogPattern(
+ search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ replacement:
#if SMAPI_FOR_WINDOWS
- "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).",
+ "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).",
#else
- "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
+ "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
#endif
- LogLevel.Error
+ logLevel: LogLevel.Error
+ ),
+
+ // save file not found error
+ new ReplaceLogPattern(
+ search: new Regex(@"^System\.IO\.FileNotFoundException: [^\n]+\n[^:]+: '[^\n]+[/\\]Saves[/\\]([^'\r\n]+)[/\\]([^'\r\n]+)'[\s\S]+LoadGameMenu\.FindSaveGames[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ replacement: "The game can't find the '$2' file for your '$1' save. See https://stardewvalleywiki.com/Saves#Troubleshooting for help.",
+ logLevel: LogLevel.Error
)
};
@@ -426,20 +435,6 @@ namespace StardewModdingAPI.Framework
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
- // write metadata file
- if (this.Settings.DumpMetadata)
- {
- ModFolderExport export = new ModFolderExport
- {
- Exported = DateTime.UtcNow.ToString("O"),
- ApiVersion = Constants.ApiVersion.ToString(),
- GameVersion = Constants.GameVersion.ToString(),
- ModFolderPath = this.ModsPath,
- Mods = mods
- };
- this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export);
- }
-
// check for updates
this.CheckForUpdatesAsync(mods);
}
@@ -774,7 +769,7 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log(
$" {metadata.DisplayName} {manifest.Version}"
+ (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
- + (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "")
+ + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}"
+ (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
LogLevel.Info
);
@@ -842,34 +837,11 @@ namespace StardewModdingAPI.Framework
{
if (metadata.Mod.Helper.Content is ContentHelper helper)
{
- helper.ObservableAssetEditors.CollectionChanged += (sender, e) =>
- {
- if (e.NewItems?.Count > 0)
- {
- this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace);
- this.ContentCore.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]);
- }
- };
- helper.ObservableAssetLoaders.CollectionChanged += (sender, e) =>
- {
- if (e.NewItems?.Count > 0)
- {
- this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace);
- this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray());
- }
- };
+ helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
+ helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
}
}
- // reset cache now if any editors or loaders were added during entry
- IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray();
- IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray();
- if (editors.Any() || loaders.Any())
- {
- this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace);
- this.ContentCore.InvalidateCacheFor(editors, loaders);
- }
-
// unlock mod integrations
this.ModRegistry.AreAllModsInitialized = true;
}
@@ -1060,26 +1032,48 @@ namespace StardewModdingAPI.Framework
// log skipped mods
if (skippedMods.Any())
{
+ // get logging logic
+ HashSet<string> logged = new HashSet<string>();
+ void LogSkippedMod(IModMetadata mod, string errorReason, string errorDetails)
+ {
+ string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
+
+ if (logged.Add($"{message}|{errorDetails}"))
+ {
+ this.Monitor.Log(message, LogLevel.Error);
+ if (errorDetails != null)
+ this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
+ }
+ }
+
+ // find skipped dependencies
+ KeyValuePair<IModMetadata, Tuple<string, string>>[] skippedDependencies;
+ {
+ HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.Key.HasID() select mod.Key.Manifest.UniqueID, StringComparer.InvariantCultureIgnoreCase);
+ foreach (IModMetadata mod in skippedMods.Keys)
+ {
+ foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds()))
+ skippedDependencyIds.Add(requiredId);
+ }
+ skippedDependencies = skippedMods.Where(p => p.Key.HasID() && skippedDependencyIds.Contains(p.Key.Manifest.UniqueID)).ToArray();
+ }
+
+ // log skipped mods
this.Monitor.Log(" Skipped mods", LogLevel.Error);
this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error);
this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error);
this.Monitor.Newline();
- HashSet<string> logged = new HashSet<string>();
- foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
+ if (skippedDependencies.Any())
{
- IModMetadata mod = pair.Key;
- string errorReason = pair.Value.Item1;
- string errorDetails = pair.Value.Item2;
- string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
-
- if (!logged.Add($"{message}|{errorDetails}"))
- continue; // skip duplicate messages (e.g. if multiple copies of the mod are installed)
-
- this.Monitor.Log(message, LogLevel.Error);
- if (errorDetails != null)
- this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
+ foreach (var pair in skippedDependencies.OrderBy(p => p.Key.DisplayName))
+ LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
+ this.Monitor.Newline();
}
+
+ foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
+ LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
this.Monitor.Newline();
}
@@ -1116,6 +1110,10 @@ namespace StardewModdingAPI.Framework
);
if (this.Settings.ParanoidWarnings)
{
+ LogWarningGroup(ModWarning.AccessesConsole, LogLevel.Warn, "Accesses the console directly",
+ "These mods directly access the SMAPI console, and you enabled paranoid warnings. (Note that this may be",
+ "legitimate and innocent usage; this warning is meaningless without further investigation.)"
+ );
LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly",
"These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be",
"legitimate and innocent usage; this warning is meaningless without further investigation.)"
@@ -1317,11 +1315,12 @@ namespace StardewModdingAPI.Framework
return;
// show friendly error if applicable
- foreach (var entry in this.ReplaceConsolePatterns)
+ foreach (ReplaceLogPattern entry in this.ReplaceConsolePatterns)
{
- if (entry.Item1.IsMatch(message))
+ string newMessage = entry.Search.Replace(message, entry.Replacement);
+ if (message != newMessage)
{
- this.Monitor.Log(entry.Item2, entry.Item3);
+ gameMonitor.Log(newMessage, entry.LogLevel);
gameMonitor.Log(message, LogLevel.Trace);
return;
}
@@ -1411,5 +1410,36 @@ namespace StardewModdingAPI.Framework
}
}
}
+
+ /// <summary>A console log pattern to replace with a different message.</summary>
+ private class ReplaceLogPattern
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The regex pattern matching the portion of the message to replace.</summary>
+ public Regex Search { get; }
+
+ /// <summary>The replacement string.</summary>
+ public string Replacement { get; }
+
+ /// <summary>The log level for the new message.</summary>
+ public LogLevel LogLevel { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="search">The regex pattern matching the portion of the message to replace.</param>
+ /// <param name="replacement">The replacement string.</param>
+ /// <param name="logLevel">The log level for the new message.</param>
+ public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel)
+ {
+ this.Search = search;
+ this.Replacement = replacement;
+ this.LogLevel = logLevel;
+ }
+ }
}
}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 47261862..d6c3b836 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -12,10 +13,12 @@ using Microsoft.Xna.Framework.Graphics;
using Netcode;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Toolkit.Serialization;
@@ -99,7 +102,7 @@ namespace StardewModdingAPI.Framework
private WatcherCore Watchers;
/// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
- private WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
+ private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
/// <summary>Whether post-game-startup initialization has been performed.</summary>
private bool IsInitialized;
@@ -133,6 +136,9 @@ namespace StardewModdingAPI.Framework
/// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks>
public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
+ /// <summary>Asset interceptors added or removed since the last tick.</summary>
+ private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
+
/*********
** Protected methods
@@ -249,6 +255,24 @@ namespace StardewModdingAPI.Framework
this.Events.ReturnedToTitle.RaiseEmpty();
}
+ /// <summary>A callback invoked when a mod adds or removes an asset interceptor.</summary>
+ /// <param name="mod">The mod which added or removed interceptors.</param>
+ /// <param name="added">The added interceptors.</param>
+ /// <param name="removed">The removed interceptors.</param>
+ internal void OnAssetInterceptorsChanged(IModMetadata mod, IEnumerable added, IEnumerable removed)
+ {
+ if (added != null)
+ {
+ foreach (object instance in added)
+ this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: true));
+ }
+ if (removed != null)
+ {
+ foreach (object instance in removed)
+ this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: false));
+ }
+ }
+
/// <summary>Constructor a content manager to read XNB files.</summary>
/// <param name="serviceProvider">The service provider to use to locate services.</param>
/// <param name="rootDirectory">The root directory to search for content.</param>
@@ -405,6 +429,38 @@ namespace StardewModdingAPI.Framework
}
/*********
+ ** Reload assets when interceptors are added/removed
+ *********/
+ if (this.ReloadAssetInterceptorsQueue.Any())
+ {
+ // get unique interceptors
+ AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
+ .GroupBy(p => p.Instance, new ObjectReferenceComparer<object>())
+ .Select(p => p.First())
+ .ToArray();
+ this.ReloadAssetInterceptorsQueue.Clear();
+
+ // log summary
+ this.Monitor.Log("Invalidating cached assets for new editors & loaders...");
+ this.Monitor.Log(
+ " changed: "
+ + string.Join(", ",
+ interceptors
+ .GroupBy(p => p.Mod)
+ .OrderBy(p => p.Key.DisplayName)
+ .Select(modGroup =>
+ $"{modGroup.Key.DisplayName} ("
+ + string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}"))
+ + ")"
+ )
+ )
+ );
+
+ // reload affected assets
+ this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset)));
+ }
+
+ /*********
** Execute commands
*********/
while (this.CommandQueue.TryDequeue(out string rawInput))
@@ -654,6 +710,16 @@ namespace StardewModdingAPI.Framework
if (locState.Objects.IsChanged)
events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed));
+ // chest items changed
+ if (events.ChestInventoryChanged.HasListeners())
+ {
+ foreach (var pair in locState.ChestItems)
+ {
+ SnapshotItemListDiff diff = pair.Value;
+ events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(pair.Key, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged));
+ }
+ }
+
// terrain features changed
if (locState.TerrainFeatures.IsChanged)
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
@@ -692,12 +758,13 @@ namespace StardewModdingAPI.Framework
}
// raise player inventory changed
- ItemStackChange[] changedItems = playerState.InventoryChanges.ToArray();
- if (changedItems.Any())
+ if (playerState.Inventory.IsChanged)
{
+ var inventory = playerState.Inventory;
+
if (this.Monitor.IsVerbose)
this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace);
- events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, changedItems));
+ events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged));
}
}
}
diff --git a/src/SMAPI/Framework/SnapshotItemListDiff.cs b/src/SMAPI/Framework/SnapshotItemListDiff.cs
new file mode 100644
index 00000000..e8ab1b1e
--- /dev/null
+++ b/src/SMAPI/Framework/SnapshotItemListDiff.cs
@@ -0,0 +1,66 @@
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Events;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>A snapshot of a tracked item list.</summary>
+ internal class SnapshotItemListDiff
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the item list changed.</summary>
+ public bool IsChanged { get; }
+
+ /// <summary>The removed values.</summary>
+ public Item[] Removed { get; }
+
+ /// <summary>The added values.</summary>
+ public Item[] Added { get; }
+
+ /// <summary>The items whose stack sizes changed.</summary>
+ public ItemStackSizeChange[] QuantityChanged { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Update the snapshot.</summary>
+ /// <param name="added">The added values.</param>
+ /// <param name="removed">The removed values.</param>
+ /// <param name="sizesChanged">The items whose stack sizes changed.</param>
+ public SnapshotItemListDiff(Item[] added, Item[] removed, ItemStackSizeChange[] sizesChanged)
+ {
+ this.Removed = removed;
+ this.Added = added;
+ this.QuantityChanged = sizesChanged;
+
+ this.IsChanged = removed.Length > 0 || added.Length > 0 || sizesChanged.Length > 0;
+ }
+
+ /// <summary>Get a snapshot diff if anything changed in the given data.</summary>
+ /// <param name="added">The added item stacks.</param>
+ /// <param name="removed">The removed item stacks.</param>
+ /// <param name="stackSizes">The items with their previous stack sizes.</param>
+ /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
+ /// <returns>Returns whether anything changed.</returns>
+ public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, out SnapshotItemListDiff changes)
+ {
+ KeyValuePair<Item, int>[] sizesChanged = stackSizes.Where(p => p.Key.Stack != p.Value).ToArray();
+ if (sizesChanged.Any() || added.Any() || removed.Any())
+ {
+ changes = new SnapshotItemListDiff(
+ added: added.ToArray(),
+ removed: removed.ToArray(),
+ sizesChanged: sizesChanged.Select(p => new ItemStackSizeChange(p.Key, p.Value, p.Key.Stack)).ToArray()
+ );
+ return true;
+ }
+
+ changes = null;
+ return false;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/ChestTracker.cs b/src/SMAPI/Framework/StateTracking/ChestTracker.cs
new file mode 100644
index 00000000..65f58ee7
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/ChestTracker.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
+using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
+using StardewValley;
+using StardewValley.Objects;
+
+namespace StardewModdingAPI.Framework.StateTracking
+{
+ /// <summary>Tracks changes to a chest's items.</summary>
+ internal class ChestTracker : IDisposable
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The item stack sizes as of the last update.</summary>
+ private readonly IDictionary<Item, int> StackSizes;
+
+ /// <summary>Items added since the last update.</summary>
+ private readonly HashSet<Item> Added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
+
+ /// <summary>Items removed since the last update.</summary>
+ private readonly HashSet<Item> Removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
+
+ /// <summary>The underlying inventory watcher.</summary>
+ private readonly ICollectionWatcher<Item> InventoryWatcher;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The chest being tracked.</summary>
+ public Chest Chest { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="chest">The chest being tracked.</param>
+ public ChestTracker(Chest chest)
+ {
+ this.Chest = chest;
+ this.InventoryWatcher = WatcherFactory.ForNetList(chest.items);
+
+ this.StackSizes = this.Chest.items
+ .Where(n => n != null)
+ .Distinct()
+ .ToDictionary(n => n, n => n.Stack);
+ }
+
+ /// <summary>Update the current values if needed.</summary>
+ public void Update()
+ {
+ // update watcher
+ this.InventoryWatcher.Update();
+ foreach (Item item in this.InventoryWatcher.Added)
+ this.Added.Add(item);
+ foreach (Item item in this.InventoryWatcher.Removed)
+ {
+ if (!this.Added.Remove(item)) // item didn't change if it was both added and removed, so remove it from both lists
+ this.Removed.Add(item);
+ }
+
+ // stop tracking removed stacks
+ foreach (Item item in this.Removed)
+ this.StackSizes.Remove(item);
+ }
+
+ /// <summary>Reset all trackers so their current values are the baseline.</summary>
+ public void Reset()
+ {
+ // update stack sizes
+ foreach (Item item in this.StackSizes.Keys.ToArray().Concat(this.Added))
+ this.StackSizes[item] = item.Stack;
+
+ // update watcher
+ this.InventoryWatcher.Reset();
+ this.Added.Clear();
+ this.Removed.Clear();
+ }
+
+ /// <summary>Get the inventory changes since the last update, if anything changed.</summary>
+ /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
+ /// <returns>Returns whether anything changed.</returns>
+ public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
+ {
+ return SnapshotItemListDiff.TryGetChanges(added: this.Added, removed: this.Removed, stackSizes: this.StackSizes, out changes);
+ }
+
+ /// <summary>Release watchers and resources.</summary>
+ public void Dispose()
+ {
+ this.StackSizes.Clear();
+ this.Added.Clear();
+ this.Removed.Clear();
+ this.InventoryWatcher.Dispose();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs
new file mode 100644
index 00000000..0b4d3030
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs
@@ -0,0 +1,143 @@
+using System.Collections.Generic;
+using Netcode;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
+
+namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
+{
+ /// <summary>A watcher which detects changes to a net list field.</summary>
+ /// <typeparam name="TValue">The list value type.</typeparam>
+ internal class NetListWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue>
+ where TValue : class, INetObject<INetSerializable>
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The field being watched.</summary>
+ private readonly NetList<TValue, NetRef<TValue>> Field;
+
+ /// <summary>The pairs added since the last reset.</summary>
+ private readonly ISet<TValue> AddedImpl = new HashSet<TValue>(new ObjectReferenceComparer<TValue>());
+
+ /// <summary>The pairs removed since the last reset.</summary>
+ private readonly ISet<TValue> RemovedImpl = new HashSet<TValue>(new ObjectReferenceComparer<TValue>());
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the collection changed since the last reset.</summary>
+ public bool IsChanged => this.AddedImpl.Count > 0 || this.RemovedImpl.Count > 0;
+
+ /// <summary>The values added since the last reset.</summary>
+ public IEnumerable<TValue> Added => this.AddedImpl;
+
+ /// <summary>The values removed since the last reset.</summary>
+ public IEnumerable<TValue> Removed => this.RemovedImpl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="field">The field to watch.</param>
+ public NetListWatcher(NetList<TValue, NetRef<TValue>> field)
+ {
+ this.Field = field;
+ field.OnElementChanged += this.OnElementChanged;
+ field.OnArrayReplaced += this.OnArrayReplaced;
+ }
+
+ /// <summary>Set the current value as the baseline.</summary>
+ public void Reset()
+ {
+ this.AddedImpl.Clear();
+ this.RemovedImpl.Clear();
+ }
+
+ /// <summary>Update the current value if needed.</summary>
+ public void Update()
+ {
+ this.AssertNotDisposed();
+ }
+
+ /// <summary>Stop watching the field and release all references.</summary>
+ public override void Dispose()
+ {
+ if (!this.IsDisposed)
+ {
+ this.Field.OnElementChanged -= this.OnElementChanged;
+ this.Field.OnArrayReplaced -= this.OnArrayReplaced;
+ }
+
+ base.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A callback invoked when the value list is replaced.</summary>
+ /// <param name="list">The net field whose values changed.</param>
+ /// <param name="oldValues">The previous list of values.</param>
+ /// <param name="newValues">The new list of values.</param>
+ private void OnArrayReplaced(NetList<TValue, NetRef<TValue>> list, IList<TValue> oldValues, IList<TValue> newValues)
+ {
+ ISet<TValue> oldSet = new HashSet<TValue>(oldValues, new ObjectReferenceComparer<TValue>());
+ ISet<TValue> changed = new HashSet<TValue>(newValues, new ObjectReferenceComparer<TValue>());
+
+ foreach (TValue value in oldSet)
+ {
+ if (!changed.Contains(value))
+ this.Remove(value);
+ }
+ foreach (TValue value in changed)
+ {
+ if (!oldSet.Contains(value))
+ this.Add(value);
+ }
+ }
+
+ /// <summary>A callback invoked when an entry is replaced.</summary>
+ /// <param name="list">The net field whose values changed.</param>
+ /// <param name="index">The list index which changed.</param>
+ /// <param name="oldValue">The previous value.</param>
+ /// <param name="newValue">The new value.</param>
+ private void OnElementChanged(NetList<TValue, NetRef<TValue>> list, int index, TValue oldValue, TValue newValue)
+ {
+ this.Remove(oldValue);
+ this.Add(newValue);
+ }
+
+ /// <summary>Track an added item.</summary>
+ /// <param name="value">The value that was added.</param>
+ private void Add(TValue value)
+ {
+ if (value == null)
+ return;
+
+ if (this.RemovedImpl.Contains(value))
+ {
+ this.AddedImpl.Remove(value);
+ this.RemovedImpl.Remove(value);
+ }
+ else
+ this.AddedImpl.Add(value);
+ }
+
+ /// <summary>Track a removed item.</summary>
+ /// <param name="value">The value that was removed.</param>
+ private void Remove(TValue value)
+ {
+ if (value == null)
+ return;
+
+ if (this.AddedImpl.Contains(value))
+ {
+ this.AddedImpl.Remove(value);
+ this.RemovedImpl.Remove(value);
+ }
+ else
+ this.RemovedImpl.Add(value);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
index 883b1023..c29d2783 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
@@ -21,6 +21,9 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>The pairs removed since the last reset.</summary>
private readonly List<TValue> RemovedImpl = new List<TValue>();
+ /// <summary>The previous values as of the last update.</summary>
+ private readonly List<TValue> PreviousValues = new List<TValue>();
+
/*********
** Accessors
@@ -78,10 +81,27 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <param name="e">The event arguments.</param>
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
- if (e.NewItems != null)
- this.AddedImpl.AddRange(e.NewItems.Cast<TValue>());
- if (e.OldItems != null)
- this.RemovedImpl.AddRange(e.OldItems.Cast<TValue>());
+ if (e.Action == NotifyCollectionChangedAction.Reset)
+ {
+ this.RemovedImpl.AddRange(this.PreviousValues);
+ this.PreviousValues.Clear();
+ }
+ else
+ {
+ TValue[] added = e.NewItems?.Cast<TValue>().ToArray();
+ TValue[] removed = e.OldItems?.Cast<TValue>().ToArray();
+
+ if (removed != null)
+ {
+ this.RemovedImpl.AddRange(removed);
+ this.PreviousValues.RemoveRange(e.OldStartingIndex, removed.Length);
+ }
+ if (added != null)
+ {
+ this.AddedImpl.AddRange(added);
+ this.PreviousValues.InsertRange(e.NewStartingIndex, added);
+ }
+ }
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
index 314ff7f5..bde43486 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
@@ -82,6 +82,14 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
return new NetCollectionWatcher<T>(collection);
}
+ /// <summary>Get a watcher for a net list.</summary>
+ /// <typeparam name="T">The value type.</typeparam>
+ /// <param name="collection">The net list.</param>
+ public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection) where T : class, INetObject<INetSerializable>
+ {
+ return new NetListWatcher<T>(collection);
+ }
+
/// <summary>Get a watcher for a net dictionary.</summary>
/// <typeparam name="TKey">The dictionary key type.</typeparam>
/// <typeparam name="TValue">The dictionary value type.</typeparam>
diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
index 1f479e12..519fe8f4 100644
--- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
@@ -5,8 +5,9 @@ using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley;
using StardewValley.Buildings;
using StardewValley.Locations;
+using StardewValley.Objects;
using StardewValley.TerrainFeatures;
-using Object = StardewValley.Object;
+using SObject = StardewValley.Object;
namespace StardewModdingAPI.Framework.StateTracking
{
@@ -42,11 +43,14 @@ namespace StardewModdingAPI.Framework.StateTracking
public ICollectionWatcher<NPC> NpcsWatcher { get; }
/// <summary>Tracks added or removed objects.</summary>
- public IDictionaryWatcher<Vector2, Object> ObjectsWatcher { get; }
+ public IDictionaryWatcher<Vector2, SObject> ObjectsWatcher { get; }
/// <summary>Tracks added or removed terrain features.</summary>
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
+ /// <summary>Tracks items added or removed to chests.</summary>
+ public IDictionary<Vector2, ChestTracker> ChestWatchers { get; } = new Dictionary<Vector2, ChestTracker>();
+
/*********
** Public methods
@@ -74,13 +78,8 @@ namespace StardewModdingAPI.Framework.StateTracking
this.ObjectsWatcher,
this.TerrainFeaturesWatcher
});
- }
- /// <summary>Stop watching the player fields and release all references.</summary>
- public void Dispose()
- {
- foreach (IWatcher watcher in this.Watchers)
- watcher.Dispose();
+ this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);
}
/// <summary>Update the current value if needed.</summary>
@@ -88,6 +87,11 @@ namespace StardewModdingAPI.Framework.StateTracking
{
foreach (IWatcher watcher in this.Watchers)
watcher.Update();
+
+ this.UpdateChestWatcherList(added: this.ObjectsWatcher.Added, removed: this.ObjectsWatcher.Removed);
+
+ foreach (var watcher in this.ChestWatchers)
+ watcher.Value.Update();
}
/// <summary>Set the current value as the baseline.</summary>
@@ -95,6 +99,46 @@ namespace StardewModdingAPI.Framework.StateTracking
{
foreach (IWatcher watcher in this.Watchers)
watcher.Reset();
+
+ foreach (var watcher in this.ChestWatchers)
+ watcher.Value.Reset();
+ }
+
+ /// <summary>Stop watching the player fields and release all references.</summary>
+ public void Dispose()
+ {
+ foreach (IWatcher watcher in this.Watchers)
+ watcher.Dispose();
+
+ foreach (var watcher in this.ChestWatchers.Values)
+ watcher.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Update the watcher list for added or removed chests.</summary>
+ /// <param name="added">The objects added to the location.</param>
+ /// <param name="removed">The objects removed from the location.</param>
+ private void UpdateChestWatcherList(IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed)
+ {
+ // remove unused watchers
+ foreach (KeyValuePair<Vector2, SObject> pair in removed)
+ {
+ if (pair.Value is Chest && this.ChestWatchers.TryGetValue(pair.Key, out ChestTracker watcher))
+ {
+ watcher.Dispose();
+ this.ChestWatchers.Remove(pair.Key);
+ }
+ }
+
+ // add new watchers
+ foreach (KeyValuePair<Vector2, SObject> pair in added)
+ {
+ if (pair.Value is Chest chest && !this.ChestWatchers.ContainsKey(pair.Key))
+ this.ChestWatchers.Add(pair.Key, new ChestTracker(chest));
+ }
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
index 6302a889..cf49a7c1 100644
--- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
@@ -2,10 +2,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Enums;
-using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley;
-using ChangeType = StardewModdingAPI.Events.ChangeType;
namespace StardewModdingAPI.Framework.StateTracking
{
@@ -99,25 +98,32 @@ namespace StardewModdingAPI.Framework.StateTracking
return this.Player.currentLocation ?? this.LastValidLocation;
}
- /// <summary>Get the player inventory changes between two states.</summary>
- public IEnumerable<ItemStackChange> GetInventoryChanges()
+ /// <summary>Get the inventory changes since the last update, if anything changed.</summary>
+ /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
+ /// <returns>Returns whether anything changed.</returns>
+ public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
{
- IDictionary<Item, int> previous = this.PreviousInventory;
IDictionary<Item, int> current = this.GetInventory();
- foreach (Item item in previous.Keys.Union(current.Keys))
+
+ ISet<Item> added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
+ ISet<Item> removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
+ foreach (Item item in this.PreviousInventory.Keys.Union(current.Keys))
{
- if (!previous.TryGetValue(item, out int prevStack))
- yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added };
- else if (!current.TryGetValue(item, out int newStack))
- yield return new ItemStackChange { Item = item, StackChange = -item.Stack, ChangeType = ChangeType.Removed };
- else if (prevStack != newStack)
- yield return new ItemStackChange { Item = item, StackChange = newStack - prevStack, ChangeType = ChangeType.StackChange };
+ if (!this.PreviousInventory.ContainsKey(item))
+ added.Add(item);
+ else if (!current.ContainsKey(item))
+ removed.Add(item);
}
+
+ return SnapshotItemListDiff.TryGetChanges(added: added, removed: removed, stackSizes: this.PreviousInventory, out changes);
}
- /// <summary>Stop watching the player fields and release all references.</summary>
+ /// <summary>Release watchers and resources.</summary>
public void Dispose()
{
+ this.PreviousInventory.Clear();
+ this.CurrentInventory?.Clear();
+
foreach (IWatcher watcher in this.Watchers)
watcher.Dispose();
}
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
index d3029540..6ae52fd0 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using Microsoft.Xna.Framework;
using StardewValley;
using StardewValley.Buildings;
+using StardewValley.Objects;
using StardewValley.TerrainFeatures;
namespace StardewModdingAPI.Framework.StateTracking.Snapshots
@@ -33,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 changed chest inventories.</summary>
+ public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>();
+
/*********
** Public methods
@@ -48,12 +52,21 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <param name="watcher">The watcher to snapshot.</param>
public void Update(LocationTracker watcher)
{
+ // main lists
this.Buildings.Update(watcher.BuildingsWatcher);
this.Debris.Update(watcher.DebrisWatcher);
this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher);
this.Npcs.Update(watcher.NpcsWatcher);
this.Objects.Update(watcher.ObjectsWatcher);
this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher);
+
+ // chest inventories
+ this.ChestItems.Clear();
+ foreach (ChestTracker tracker in watcher.ChestWatchers.Values)
+ {
+ if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff changes))
+ this.ChestItems[tracker.Chest] = changes;
+ }
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
index 7bcd9f82..f0fb9485 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
@@ -11,6 +11,13 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
internal class PlayerSnapshot
{
/*********
+ ** Fields
+ *********/
+ /// <summary>An empty item list diff.</summary>
+ private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(new Item[0], new Item[0], new ItemStackSizeChange[0]);
+
+
+ /*********
** Accessors
*********/
/// <summary>The player being tracked.</summary>
@@ -27,7 +34,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
.ToDictionary(skill => skill, skill => new SnapshotDiff<int>());
/// <summary>Get a list of inventory changes.</summary>
- public IEnumerable<ItemStackChange> InventoryChanges { get; private set; }
+ public SnapshotItemListDiff Inventory { get; private set; }
/*********
@@ -47,7 +54,11 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
this.Location.Update(watcher.LocationWatcher);
foreach (var pair in this.Skills)
pair.Value.Update(watcher.SkillWatchers[pair.Key]);
- this.InventoryChanges = watcher.GetInventoryChanges().ToArray();
+
+ this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges)
+ ? itemChanges
+ : this.EmptyItemListDiff;
+
}
}
}
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index 1c0a04f0..b86a6790 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Xna.Framework.Graphics;
+using Netcode;
using StardewModdingAPI.Framework.Reflection;
using StardewValley;
using StardewValley.BellsAndWhistles;
@@ -11,6 +12,7 @@ using StardewValley.Characters;
using StardewValley.GameData.Movies;
using StardewValley.Locations;
using StardewValley.Menus;
+using StardewValley.Network;
using StardewValley.Objects;
using StardewValley.Projectiles;
using StardewValley.TerrainFeatures;
@@ -65,8 +67,8 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload one of the game's core assets (if applicable).</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="assets">The asset keys and types to reload.</param>
- /// <returns>Returns the number of reloaded assets.</returns>
- public int Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
+ /// <returns>Returns a lookup of asset names to whether they've been propagated.</returns>
+ public IDictionary<string, bool> Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
{
// group into optimized lists
var buckets = assets.GroupBy(p =>
@@ -81,25 +83,26 @@ namespace StardewModdingAPI.Metadata
});
// reload assets
- int reloaded = 0;
+ IDictionary<string, bool> propagated = assets.ToDictionary(p => p.Key, p => false, StringComparer.InvariantCultureIgnoreCase);
foreach (var bucket in buckets)
{
switch (bucket.Key)
{
case AssetBucket.Sprite:
- reloaded += this.ReloadNpcSprites(content, bucket.Select(p => p.Key));
+ this.ReloadNpcSprites(content, bucket.Select(p => p.Key), propagated);
break;
case AssetBucket.Portrait:
- reloaded += this.ReloadNpcPortraits(content, bucket.Select(p => p.Key));
+ this.ReloadNpcPortraits(content, bucket.Select(p => p.Key), propagated);
break;
default:
- reloaded += bucket.Count(p => this.PropagateOther(content, p.Key, p.Value));
+ foreach (var entry in bucket)
+ propagated[entry.Key] = this.PropagateOther(content, entry.Key, entry.Value);
break;
}
}
- return reloaded;
+ return propagated;
}
@@ -193,7 +196,7 @@ namespace StardewModdingAPI.Metadata
return true;
case "characters\\farmer\\farmer_girl_base": // Farmer
- case "characters\\farmer\\farmer_girl_bald":
+ case "characters\\farmer\\farmer_girl_base_bald":
if (Game1.player == null || Game1.player.IsMale)
return false;
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
@@ -226,6 +229,31 @@ namespace StardewModdingAPI.Metadata
Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key);
return true;
+ case "data\\bundles": // NetWorldState constructor
+ {
+ var bundles = this.Reflection.GetField<NetBundles>(Game1.netWorldState.Value, "bundles").GetValue();
+ var rewards = this.Reflection.GetField<NetIntDictionary<bool, NetBool>>(Game1.netWorldState.Value, "bundleRewards").GetValue();
+ foreach (var pair in content.Load<Dictionary<string, string>>(key))
+ {
+ int bundleKey = int.Parse(pair.Key.Split('/')[1]);
+ int rewardsCount = pair.Value.Split('/')[2].Split(' ').Length;
+
+ // add bundles
+ if (!bundles.TryGetValue(bundleKey, out bool[] values) || values.Length < rewardsCount)
+ {
+ values ??= new bool[0];
+
+ bundles.Remove(bundleKey);
+ bundles[bundleKey] = values.Concat(Enumerable.Repeat(false, rewardsCount - values.Length)).ToArray();
+ }
+
+ // add bundle rewards
+ if (!rewards.ContainsKey(bundleKey))
+ rewards[bundleKey] = false;
+ }
+ }
+ break;
+
case "data\\clothinginformation": // Game1.LoadContent
Game1.clothingInformation = content.Load<Dictionary<int, string>>(key);
return true;
@@ -474,10 +502,18 @@ namespace StardewModdingAPI.Metadata
/****
** Content\TerrainFeatures
****/
- case "terrainfeatures\\flooring": // Flooring
+ case "terrainfeatures\\flooring": // from Flooring
Flooring.floorsTexture = content.Load<Texture2D>(key);
return true;
+ case "terrainfeatures\\flooring_winter": // from Flooring
+ Flooring.floorsTextureWinter = content.Load<Texture2D>(key);
+ return true;
+
+ case "terrainfeatures\\grass": // from Grass
+ this.ReloadGrassTextures(content, key);
+ return true;
+
case "terrainfeatures\\hoedirt": // from HoeDirt
HoeDirt.lightTexture = content.Load<Texture2D>(key);
return true;
@@ -607,7 +643,7 @@ namespace StardewModdingAPI.Metadata
{
// get buildings
string type = Path.GetFileName(key);
- Building[] buildings = Game1.locations
+ Building[] buildings = this.GetLocations(buildingInteriors: false)
.OfType<BuildableGameLocation>()
.SelectMany(p => p.buildings)
.Where(p => p.buildingType.Value == type)
@@ -694,6 +730,35 @@ namespace StardewModdingAPI.Metadata
return true;
}
+ /// <summary>Reload tree 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 ReloadGrassTextures(LocalizedContentManager content, string key)
+ {
+ Grass[] grasses =
+ (
+ from location in this.GetLocations()
+ from grass in location.terrainFeatures.Values.OfType<Grass>()
+ let textureName = this.NormalizeAssetNameIgnoringEmpty(
+ this.Reflection.GetMethod(grass, "textureName").Invoke<string>()
+ )
+ where textureName == key
+ select grass
+ )
+ .ToArray();
+
+ if (grasses.Any())
+ {
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+ foreach (Grass grass in grasses)
+ this.Reflection.GetField<Lazy<Texture2D>>(grass, "texture").SetValue(texture);
+ return true;
+ }
+
+ return false;
+ }
+
/// <summary>Reload the disposition data for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
@@ -717,51 +782,57 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="keys">The asset keys to reload.</param>
- /// <returns>Returns the number of reloaded assets.</returns>
- private int ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys)
+ /// <param name="propagated">The asset keys which have been propagated.</param>
+ private void ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
{
// get NPCs
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
- NPC[] characters = this.GetCharacters()
- .Where(npc => npc.Sprite != null && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)))
+ var characters =
+ (
+ from npc in this.GetCharacters()
+ let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)
+ where key != null && lookup.Contains(key)
+ select new { Npc = npc, Key = key }
+ )
.ToArray();
if (!characters.Any())
- return 0;
+ return;
// update sprite
- int reloaded = 0;
- foreach (NPC npc in characters)
+ foreach (var target in characters)
{
- this.SetSpriteTexture(npc.Sprite, content.Load<Texture2D>(npc.Sprite.textureName.Value));
- reloaded++;
+ this.SetSpriteTexture(target.Npc.Sprite, content.Load<Texture2D>(target.Key));
+ propagated[target.Key] = true;
}
-
- return reloaded;
}
/// <summary>Reload the portraits for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="keys">The asset key to reload.</param>
- /// <returns>Returns the number of reloaded assets.</returns>
- private int ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys)
+ /// <param name="propagated">The asset keys which have been propagated.</param>
+ private void ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
{
// get NPCs
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
- var villagers = this
- .GetCharacters()
- .Where(npc => npc.isVillager() && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)))
+ var characters =
+ (
+ from npc in this.GetCharacters()
+ where npc.isVillager()
+
+ let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)
+ where key != null && lookup.Contains(key)
+ select new { Npc = npc, Key = key }
+ )
.ToArray();
- if (!villagers.Any())
- return 0;
+ if (!characters.Any())
+ return;
// update portrait
- int reloaded = 0;
- foreach (NPC npc in villagers)
+ foreach (var target in characters)
{
- npc.Portrait = content.Load<Texture2D>(npc.Portrait.Name);
- reloaded++;
+ target.Npc.Portrait = content.Load<Texture2D>(target.Key);
+ propagated[target.Key] = true;
}
- return reloaded;
}
/// <summary>Reload tree textures.</summary>
@@ -771,7 +842,7 @@ namespace StardewModdingAPI.Metadata
/// <returns>Returns whether any textures were reloaded.</returns>
private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type)
{
- Tree[] trees = Game1.locations
+ Tree[] trees = this.GetLocations()
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
.Where(tree => tree.treeType.Value == type)
.ToArray();
@@ -876,7 +947,8 @@ namespace StardewModdingAPI.Metadata
}
/// <summary>Get all locations in the game.</summary>
- private IEnumerable<GameLocation> GetLocations()
+ /// <param name="buildingInteriors">Whether to also get the interior locations for constructable buildings.</param>
+ private IEnumerable<GameLocation> GetLocations(bool buildingInteriors = true)
{
// get available root locations
IEnumerable<GameLocation> rootLocations = Game1.locations;
@@ -888,7 +960,7 @@ namespace StardewModdingAPI.Metadata
{
yield return location;
- if (location is BuildableGameLocation buildableLocation)
+ if (buildingInteriors && location is BuildableGameLocation buildableLocation)
{
foreach (Building building in buildableLocation.buildings)
{
diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs
index 95482708..eee5c235 100644
--- a/src/SMAPI/Metadata/InstructionMetadata.cs
+++ b/src/SMAPI/Metadata/InstructionMetadata.cs
@@ -60,6 +60,7 @@ namespace StardewModdingAPI.Metadata
if (paranoidMode)
{
// filesystem access
+ yield return new TypeFinder(typeof(System.Console).FullName, InstructionHandleResult.DetectedConsoleAccess);
yield return new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess);
yield return new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess);
yield return new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess);
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index a7381b91..824bb783 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -60,12 +60,6 @@ The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to
"LogNetworkTraffic": false,
/**
- * Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod
- * metadata for detected mods. This is only needed when troubleshooting some cases.
- */
- "DumpMetadata": false,
-
- /**
* The colors to use for text written to the SMAPI console.
*
* The possible values for 'UseScheme' are:
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index 4952116f..936c420d 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -99,9 +99,30 @@
<Link>SMAPI.metadata.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
+ <None Update="i18n\de.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="i18n\es.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="i18n\ja.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
<None Update="i18n\default.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
+ <None Update="i18n\pt.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="i18n\ru.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="i18n\tr.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="i18n\zh.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
<None Update="steam_appid.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
diff --git a/src/SMAPI/i18n/es.json b/src/SMAPI/i18n/es.json
new file mode 100644
index 00000000..f5a74dfe
--- /dev/null
+++ b/src/SMAPI/i18n/es.json
@@ -0,0 +1,3 @@
+{
+ "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)."
+}
diff --git a/src/SMAPI/i18n/ja.json b/src/SMAPI/i18n/ja.json
new file mode 100644
index 00000000..9bbc285e
--- /dev/null
+++ b/src/SMAPI/i18n/ja.json
@@ -0,0 +1,3 @@
+{
+ "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)"
+}
diff --git a/src/SMAPI/i18n/pt.json b/src/SMAPI/i18n/pt.json
new file mode 100644
index 00000000..59273680
--- /dev/null
+++ b/src/SMAPI/i18n/pt.json
@@ -0,0 +1,3 @@
+{
+ "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)."
+}
diff --git a/src/SMAPI/i18n/zh.json b/src/SMAPI/i18n/zh.json
index bbd6a574..9c0e0c21 100644
--- a/src/SMAPI/i18n/zh.json
+++ b/src/SMAPI/i18n/zh.json
@@ -1,3 +1,3 @@
-{
- "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)"
-}
+{
+ "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)"
+}