diff options
Diffstat (limited to 'src/SMAPI')
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控制台获得更多信息)" +} |