From 7a91cf1cd875bc62072571e1b259a10cbacdaaab Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 9 May 2022 00:18:37 -0400 Subject: [PATCH 01/13] update schema for Content Patcher 1.26.0 --- docs/release-notes.md | 4 ++++ src/SMAPI.Web/wwwroot/schemas/content-patcher.json | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index 98747613..23783a8a 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,10 @@ ← [README](README.md) # Release notes +## Upcoming release +* For the web UI: + * Updated the JSON validator/schema for Content Patcher 1.26.0. + ## 3.14.2 Released 08 May 2022 for Stardew Valley 1.5.6 or later. diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index 4975a973..f0fe74c2 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -14,9 +14,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features, avoid obsolete behavior, and reduce load times.", "type": "string", - "const": "1.25.0", + "const": "1.26.0", "@errorMessages": { - "const": "Incorrect value '@value'. You should always use the latest format version (currently 1.25.0) to enable the latest features, avoid obsolete behavior, and reduce load times." + "const": "Incorrect value '@value'. You should always use the latest format version (currently 1.26.0) to enable the latest features, avoid obsolete behavior, and reduce load times." } }, "ConfigSchema": { @@ -51,6 +51,11 @@ "description": "An optional explanation of the config field for players, shown in UIs like Generic Mod Config Menu.", "type": "string" }, + "Section": { + "title": "Section", + "description": "An optional section key to group related fields on config UIs. This can be the literal text to show, or you can add a translation with the key 'config.section.
.name' and '.description' to add a translated name & tooltip.", + "type": "string" + }, "additionalProperties": false }, From eb01aa275b272774c48672a2560455d3fb902a4e Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 10 May 2022 18:51:37 -0400 Subject: [PATCH 02/13] fix asset propagation for player sprite recolor masks --- docs/release-notes.md | 3 +++ src/SMAPI/Metadata/CoreAssetPropagator.cs | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index 23783a8a..3ba11edd 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,9 @@ # Release notes ## Upcoming release +* For mod authors: + * Fixed asset propagation for player sprites not fully updating recolor masks in some cases. + * For the web UI: * Updated the JSON validator/schema for Content Patcher 1.26.0. diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 12b73515..e014f9a9 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -1017,7 +1017,9 @@ namespace StardewModdingAPI.Metadata foreach (Farmer player in players) { - this.Reflection.GetField>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue()?.Remove(player.getTexture()); + var recolorOffsets = this.Reflection.GetField>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue(); + recolorOffsets?.Clear(); + player.FarmerRenderer.MarkSpriteDirty(); } From 8c8ec6a4572fd3e59b738fc7545fcddc764f4519 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 10 May 2022 23:05:24 -0400 Subject: [PATCH 03/13] remove unused IsPerformanceCritical event field --- src/SMAPI/Framework/Events/EventManager.cs | 34 ++++++++++----------- src/SMAPI/Framework/Events/IManagedEvent.cs | 3 -- src/SMAPI/Framework/Events/ManagedEvent.cs | 7 +---- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 41540047..b21d5c7d 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -198,9 +198,9 @@ namespace StardewModdingAPI.Framework.Events public EventManager(ModRegistry modRegistry) { // create shortcut initializers - ManagedEvent ManageEventOf(string typeName, string eventName, bool isPerformanceCritical = false) + ManagedEvent ManageEventOf(string typeName, string eventName) { - return new ManagedEvent($"{typeName}.{eventName}", modRegistry, isPerformanceCritical); + return new ManagedEvent($"{typeName}.{eventName}", modRegistry); } // init events @@ -210,21 +210,21 @@ namespace StardewModdingAPI.Framework.Events this.LocaleChanged = ManageEventOf(nameof(IModEvents.Content), nameof(IContentEvents.LocaleChanged)); this.MenuChanged = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); - this.Rendering = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true); - this.Rendered = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true); - this.RenderingWorld = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld), isPerformanceCritical: true); - this.RenderedWorld = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld), isPerformanceCritical: true); - this.RenderingActiveMenu = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu), isPerformanceCritical: true); - this.RenderedActiveMenu = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu), isPerformanceCritical: true); - this.RenderingHud = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud), isPerformanceCritical: true); - this.RenderedHud = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud), isPerformanceCritical: true); + this.Rendering = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering)); + this.Rendered = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered)); + this.RenderingWorld = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld)); + this.RenderedWorld = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld)); + this.RenderingActiveMenu = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu)); + this.RenderedActiveMenu = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu)); + this.RenderingHud = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud)); + this.RenderedHud = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud)); this.WindowResized = ManageEventOf(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized)); this.GameLaunched = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched)); - this.UpdateTicking = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking), isPerformanceCritical: true); - this.UpdateTicked = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked), isPerformanceCritical: true); - this.OneSecondUpdateTicking = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking), isPerformanceCritical: true); - this.OneSecondUpdateTicked = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked), isPerformanceCritical: true); + this.UpdateTicking = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking)); + this.UpdateTicked = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked)); + this.OneSecondUpdateTicking = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking)); + this.OneSecondUpdateTicked = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked)); this.SaveCreating = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating)); this.SaveCreated = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated)); this.Saving = ManageEventOf(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving)); @@ -238,7 +238,7 @@ namespace StardewModdingAPI.Framework.Events this.ButtonsChanged = ManageEventOf(nameof(IModEvents.Input), nameof(IInputEvents.ButtonsChanged)); this.ButtonPressed = ManageEventOf(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); this.ButtonReleased = ManageEventOf(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); - this.CursorMoved = ManageEventOf(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true); + this.CursorMoved = ManageEventOf(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved)); this.MouseWheelScrolled = ManageEventOf(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); this.PeerContextReceived = ManageEventOf(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived)); @@ -261,8 +261,8 @@ namespace StardewModdingAPI.Framework.Events this.FurnitureListChanged = ManageEventOf(nameof(IModEvents.World), nameof(IWorldEvents.FurnitureListChanged)); this.LoadStageChanged = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); - this.UnvalidatedUpdateTicking = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true); - this.UnvalidatedUpdateTicked = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true); + this.UnvalidatedUpdateTicking = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking)); + this.UnvalidatedUpdateTicked = ManageEventOf(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked)); } } } diff --git a/src/SMAPI/Framework/Events/IManagedEvent.cs b/src/SMAPI/Framework/Events/IManagedEvent.cs index e4e3ca08..0ae8c55a 100644 --- a/src/SMAPI/Framework/Events/IManagedEvent.cs +++ b/src/SMAPI/Framework/Events/IManagedEvent.cs @@ -8,8 +8,5 @@ namespace StardewModdingAPI.Framework.Events *********/ /// A human-readable name for the event. string EventName { get; } - - /// Whether the event is typically called at least once per second. - bool IsPerformanceCritical { get; } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index 4b8a770d..a16beb77 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -36,9 +36,6 @@ namespace StardewModdingAPI.Framework.Events /// public string EventName { get; } - /// - public bool IsPerformanceCritical { get; } - /********* ** Public methods @@ -46,12 +43,10 @@ namespace StardewModdingAPI.Framework.Events /// Construct an instance. /// A human-readable name for the event. /// The mod registry with which to identify mods. - /// Whether the event is typically called at least once per second. - public ManagedEvent(string eventName, ModRegistry modRegistry, bool isPerformanceCritical = false) + public ManagedEvent(string eventName, ModRegistry modRegistry) { this.EventName = eventName; this.ModRegistry = modRegistry; - this.IsPerformanceCritical = isPerformanceCritical; } /// Get whether anything is listening to the event. From e14916f9622592a993fad54fe184a03de0b95c7b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 17:12:58 -0400 Subject: [PATCH 04/13] add error code to SContentLoadException --- .../ContentManagers/BaseContentManager.cs | 4 +-- .../ContentManagers/ModContentManager.cs | 27 ++++++++++--------- .../Exceptions/ContentLoadErrorType.cs | 21 +++++++++++++++ .../Exceptions/SContentLoadException.cs | 15 +++++++++-- .../Framework/ModHelpers/ContentHelper.cs | 4 +-- .../Framework/ModHelpers/GameContentHelper.cs | 2 +- .../Framework/ModHelpers/ModContentHelper.cs | 2 +- 7 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 src/SMAPI/Framework/Exceptions/ContentLoadErrorType.cs diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index e4695588..575d252e 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -198,9 +198,9 @@ namespace StardewModdingAPI.Framework.ContentManagers // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid // throwing other types like ArgumentException here. if (string.IsNullOrWhiteSpace(assetName)) - throw new SContentLoadException("The asset key or local path is empty."); + throw new SContentLoadException(ContentLoadErrorType.InvalidName, "The asset key or local path is empty."); if (assetName.Intersect(Path.GetInvalidPathChars()).Any()) - throw new SContentLoadException("The asset key or local path contains invalid characters."); + throw new SContentLoadException(ContentLoadErrorType.InvalidName, "The asset key or local path contains invalid characters."); return this.Cache.NormalizeKey(assetName); } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 7cac8f36..85e109c8 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -94,7 +94,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) { if (contentManagerID != this.Name) - throw this.GetLoadError(assetName, "can't load a different mod's managed asset key through this mod content manager."); + throw this.GetLoadError(assetName, ContentLoadErrorType.AccessDenied, "can't load a different mod's managed asset key through this mod content manager."); assetName = relativePath; } } @@ -106,7 +106,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // get file FileInfo file = this.GetModFile(assetName.Name); if (!file.Exists) - throw this.GetLoadError(assetName, "the specified path doesn't exist."); + throw this.GetLoadError(assetName, ContentLoadErrorType.AssetDoesNotExist, "the specified path doesn't exist."); // load content asset = file.Extension.ToLower() switch @@ -121,7 +121,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } catch (Exception ex) when (ex is not SContentLoadException) { - throw this.GetLoadError(assetName, "an unexpected occurred.", ex); + throw this.GetLoadError(assetName, ContentLoadErrorType.Other, "an unexpected occurred.", ex); } // track & return asset @@ -157,7 +157,7 @@ namespace StardewModdingAPI.Framework.ContentManagers { // validate if (!typeof(T).IsAssignableFrom(typeof(XmlSource))) - throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'."); + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'."); // load string source = File.ReadAllText(file.FullName); @@ -171,7 +171,7 @@ namespace StardewModdingAPI.Framework.ContentManagers private T LoadDataFile(IAssetName assetName, FileInfo file) { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) - throw this.GetLoadError(assetName, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method return asset; } @@ -184,7 +184,7 @@ namespace StardewModdingAPI.Framework.ContentManagers { // validate if (typeof(T) != typeof(Texture2D)) - throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); // load using FileStream stream = File.OpenRead(file.FullName); @@ -201,7 +201,7 @@ namespace StardewModdingAPI.Framework.ContentManagers { // validate if (typeof(T) != typeof(Map)) - throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidData, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); // load FormatManager formatManager = FormatManager.Instance; @@ -239,16 +239,17 @@ namespace StardewModdingAPI.Framework.ContentManagers /// The file to load. private T HandleUnknownFileType(IAssetName assetName, FileInfo file) { - throw this.GetLoadError(assetName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); + throw this.GetLoadError(assetName, ContentLoadErrorType.InvalidName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); } /// Get an error which indicates that an asset couldn't be loaded. + /// Why loading an asset through the content pipeline failed. /// The asset name that failed to load. /// The reason the file couldn't be loaded. /// The underlying exception, if applicable. - private SContentLoadException GetLoadError(IAssetName assetName, string reasonPhrase, Exception? exception = null) + private SContentLoadException GetLoadError(IAssetName assetName, ContentLoadErrorType errorType, string reasonPhrase, Exception? exception = null) { - return new($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); + return new(errorType, $"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); } /// Get a file from the mod folder. @@ -328,13 +329,13 @@ namespace StardewModdingAPI.Framework.ContentManagers // validate tilesheet path string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new SContentLoadException($"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); + throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); // load best match try { if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error)) - throw new SContentLoadException($"{errorPrefix} {error}"); + throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} {error}"); if (assetName is not null) { @@ -346,7 +347,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } catch (Exception ex) when (ex is not SContentLoadException) { - throw new SContentLoadException($"{errorPrefix} The tilesheet couldn't be loaded.", ex); + throw new SContentLoadException(ContentLoadErrorType.InvalidData, $"{errorPrefix} The tilesheet couldn't be loaded.", ex); } } } diff --git a/src/SMAPI/Framework/Exceptions/ContentLoadErrorType.cs b/src/SMAPI/Framework/Exceptions/ContentLoadErrorType.cs new file mode 100644 index 00000000..16689b67 --- /dev/null +++ b/src/SMAPI/Framework/Exceptions/ContentLoadErrorType.cs @@ -0,0 +1,21 @@ +namespace StardewModdingAPI.Framework.Exceptions +{ + /// Indicates why loading an asset through the content pipeline failed. + internal enum ContentLoadErrorType + { + /// The asset name is empty or has an invalid format. + InvalidName, + + /// The asset doesn't exist. + AssetDoesNotExist, + + /// The asset is not available in the current context (e.g. an attempt to load another mod's assets). + AccessDenied, + + /// The asset exists, but the data could not be deserialized or it doesn't match the expected type. + InvalidData, + + /// An unknown error occurred. + Other + } +} diff --git a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs index be1fe748..4db24d06 100644 --- a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs +++ b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs @@ -6,13 +6,24 @@ namespace StardewModdingAPI.Framework.Exceptions /// An implementation of used by SMAPI to detect whether it was thrown by SMAPI or the underlying framework. internal class SContentLoadException : ContentLoadException { + /********* + ** Accessors + *********/ + /// Why loading the asset through the content pipeline failed. + public ContentLoadErrorType ErrorType { get; } + + /********* ** Public methods *********/ /// Construct an instance. + /// Why loading the asset through the content pipeline failed. /// The error message. /// The underlying exception, if any. - public SContentLoadException(string message, Exception? ex = null) - : base(message, ex) { } + public SContentLoadException(ContentLoadErrorType errorType, string message, Exception? ex = null) + : base(message, ex) + { + this.ErrorType = errorType; + } } } diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 6a92da24..24e511c3 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -135,12 +135,12 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ModContentManager.LoadExact(assetName, useCache: false); default: - throw new SContentLoadException($"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); + throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); } } catch (Exception ex) when (ex is not SContentLoadException) { - throw new SContentLoadException($"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}.", ex); + throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}.", ex); } } diff --git a/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs index 232e9287..7c4eda89 100644 --- a/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs @@ -85,7 +85,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } catch (Exception ex) when (ex is not SContentLoadException) { - throw new SContentLoadException($"{this.ModName} failed loading content asset '{assetName}' from the game content.", ex); + throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.ModName} failed loading content asset '{assetName}' from the game content.", ex); } } diff --git a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs index 6429f9bf..5fcb80b2 100644 --- a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } catch (Exception ex) when (ex is not SContentLoadException) { - throw new SContentLoadException($"{this.ModName} failed loading content asset '{relativePath}' from its mod folder.", ex); + throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.ModName} failed loading content asset '{relativePath}' from its mod folder.", ex); } } From d097825c84bbe7d4b4812d4948358dd22abd166a Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 17:25:06 -0400 Subject: [PATCH 05/13] fix error when mod loads XNB mod file without extension --- docs/release-notes.md | 1 + .../Framework/ModHelpers/ContentHelper.cs | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index 3ba11edd..d66fea5d 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -3,6 +3,7 @@ # Release notes ## Upcoming release * For mod authors: + * Fixed error when loading a `.xnb` file through the old content API without the file extension. * Fixed asset propagation for player sprites not fully updating recolor masks in some cases. * For the web UI: diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 24e511c3..427adac2 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -132,7 +132,32 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.GameContentManager.LoadLocalized(assetName, this.CurrentLocaleConstant, useCache: false); case ContentSource.ModFolder: - return this.ModContentManager.LoadExact(assetName, useCache: false); + try + { + return this.ModContentManager.LoadExact(assetName, useCache: false); + } + catch (SContentLoadException ex) when (ex.ErrorType == ContentLoadErrorType.AssetDoesNotExist) + { + // legacy behavior: you can load a .xnb file without the file extension + try + { + IAssetName newName = this.ContentCore.ParseAssetName(assetName.Name + ".xnb", allowLocales: false); + if (this.ModContentManager.DoesAssetExist(newName)) + { + T data = this.ModContentManager.LoadExact(newName, useCache: false); + SCore.DeprecationManager.Warn( + this.Mod, + "loading XNB files from the mod folder without the .xnb file extension", + "3.14.0", + DeprecationLevel.Notice + ); + return data; + } + } + catch { /* legacy behavior failed, rethrow original error */ } + + throw; + } default: throw new SContentLoadException(ContentLoadErrorType.Other, $"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); From 45f674303454fb27327a0404ed403ac15ed04580 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 17:59:44 -0400 Subject: [PATCH 06/13] optimize raising events for the most common cases --- docs/release-notes.md | 3 + src/SMAPI/Framework/Events/ManagedEvent.cs | 81 +++++++++++++++------- src/SMAPI/Framework/SCore.cs | 3 +- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index d66fea5d..7ccd466a 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,9 @@ # Release notes ## Upcoming release +* For players: + * Further improved performance in some cases. + * For mod authors: * Fixed error when loading a `.xnb` file through the old content API without the file extension. * Fixed asset propagation for player sprites not fully updating recolor masks in some cases. diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index a16beb77..abeea098 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -20,14 +20,17 @@ namespace StardewModdingAPI.Framework.Events /// The underlying event handlers. private readonly List> Handlers = new(); - /// A cached snapshot of , or null to rebuild it next raise. + /// A cached snapshot of the sorted by event priority, or null to rebuild it next raise. private ManagedEventHandler[]? CachedHandlers = Array.Empty>(); /// The total number of event handlers registered for this events, regardless of whether they're still registered. private int RegistrationIndex; - /// Whether new handlers were added since the last raise. - private bool HasNewHandlers; + /// Whether handlers were removed since the last raise. + private bool HasRemovedHandlers; + + /// Whether any of the handlers have a custom priority. + private bool HasPriorities; /********* @@ -67,7 +70,7 @@ namespace StardewModdingAPI.Framework.Events this.Handlers.Add(managedHandler); this.CachedHandlers = null; - this.HasNewHandlers = true; + this.HasPriorities |= priority != EventPriority.Normal; } } @@ -85,6 +88,7 @@ namespace StardewModdingAPI.Framework.Events this.Handlers.RemoveAt(i); this.CachedHandlers = null; + this.HasRemovedHandlers = true; break; } } @@ -92,10 +96,24 @@ namespace StardewModdingAPI.Framework.Events /// Raise the event and notify all handlers. /// The event arguments to pass. - /// A lambda which returns true if the event should be raised for the given mod. - public void Raise(TEventArgs args, Func? match = null) + public void Raise(TEventArgs args) { - this.Raise((_, invoke) => invoke(args), match); + // skip if no handlers + if (this.Handlers.Count == 0) + return; + + // raise event + foreach (ManagedEventHandler handler in this.GetHandlers()) + { + try + { + handler.Handler(null, args); + } + catch (Exception ex) + { + this.LogError(handler, ex); + } + } } /// Raise the event and notify all handlers. @@ -107,31 +125,15 @@ namespace StardewModdingAPI.Framework.Events if (this.Handlers.Count == 0) return; - // update cached data - // (This is debounced here to avoid repeatedly sorting when handlers are added/removed, - // and keeping a separate cached list allows changes during enumeration.) - var handlers = this.CachedHandlers; // iterate local copy in case a mod adds/removes a handler while handling the event, which will set this field to null - if (handlers == null) - { - lock (this.Handlers) - { - if (this.HasNewHandlers && this.Handlers.Any(p => p.Priority != EventPriority.Normal)) - this.Handlers.Sort(); - - this.CachedHandlers = handlers = this.Handlers.ToArray(); - this.HasNewHandlers = false; - } - } - // raise event - foreach (ManagedEventHandler handler in handlers) + foreach (ManagedEventHandler handler in this.GetHandlers()) { if (match != null && !match(handler.SourceMod)) continue; try { - invoke(handler.SourceMod, args => handler.Handler.Invoke(null, args)); + invoke(handler.SourceMod, args => handler.Handler(null, args)); } catch (Exception ex) { @@ -147,9 +149,36 @@ namespace StardewModdingAPI.Framework.Events /// Log an exception from an event handler. /// The event handler instance. /// The exception that was raised. - protected void LogError(ManagedEventHandler handler, Exception ex) + private void LogError(ManagedEventHandler handler, Exception ex) { handler.SourceMod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); } + + /// Get cached copy of the sorted handlers to invoke. + /// This returns the handlers sorted by priority, and allows iterating the list even if a mod adds/removes handlers while handling it. This is debounced when requested to avoid repeatedly sorting when handlers are added/removed. + private ManagedEventHandler[] GetHandlers() + { + ManagedEventHandler[]? handlers = this.CachedHandlers; + + if (handlers == null) + { + lock (this.Handlers) + { + // recheck priorities + if (this.HasRemovedHandlers) + this.HasPriorities = this.Handlers.Any(p => p.Priority != EventPriority.Normal); + + // sort by priority if needed + if (this.HasPriorities) + this.Handlers.Sort(); + + // update cache + this.CachedHandlers = handlers = this.Handlers.ToArray(); + this.HasRemovedHandlers = false; + } + } + + return handlers; + } } } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index f882682e..c3f0c05f 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1231,7 +1231,8 @@ namespace StardewModdingAPI.Framework modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender // raise events - this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => modIDs.Contains(mod.Manifest.UniqueID)); + var args = new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper); + this.EventManager.ModMessageReceived.Raise((_, invoke) => invoke(args), mod => modIDs.Contains(mod.Manifest.UniqueID)); } /// Constructor a content manager to read game content files. From cae1063ad99a29aed3a0c162bad1d2842376c608 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 19:15:22 -0400 Subject: [PATCH 07/13] move filtering only used in one place out of managed event --- src/SMAPI/Framework/Events/ManagedEvent.cs | 6 +----- src/SMAPI/Framework/SCore.cs | 13 +++++++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index abeea098..a34106ce 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -118,8 +118,7 @@ namespace StardewModdingAPI.Framework.Events /// Raise the event and notify all handlers. /// Invoke an event handler. This receives the mod which registered the handler, and should invoke the callback with the event arguments to pass it. - /// A lambda which returns true if the event should be raised for the given mod. - public void Raise(Action> invoke, Func? match = null) + public void Raise(Action> invoke) { // skip if no handlers if (this.Handlers.Count == 0) @@ -128,9 +127,6 @@ namespace StardewModdingAPI.Framework.Events // raise event foreach (ManagedEventHandler handler in this.GetHandlers()) { - if (match != null && !match(handler.SourceMod)) - continue; - try { invoke(handler.SourceMod, args => handler.Handler(null, args)); diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index c3f0c05f..1fea6d69 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1231,8 +1231,17 @@ namespace StardewModdingAPI.Framework modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender // raise events - var args = new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper); - this.EventManager.ModMessageReceived.Raise((_, invoke) => invoke(args), mod => modIDs.Contains(mod.Manifest.UniqueID)); + ModMessageReceivedEventArgs? args = null; + this.EventManager.ModMessageReceived.Raise( + invoke: (mod, invoke) => + { + if (modIDs.Contains(mod.Manifest.UniqueID)) + { + args ??= new(message, this.Toolkit.JsonHelper); + invoke(args); + } + } + ); } /// Constructor a content manager to read game content files. From 05b39b7cd9a40f1660c2c53ef70b75a67f2fccc3 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 19:26:28 -0400 Subject: [PATCH 08/13] cache verbose flag in main update method --- src/SMAPI/Framework/SCore.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 1fea6d69..667fcf0f 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -577,6 +577,7 @@ namespace StardewModdingAPI.Framework private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action runUpdate) { EventManager events = this.EventManager; + bool verbose = this.Monitor.IsVerbose; try { @@ -804,7 +805,7 @@ namespace StardewModdingAPI.Framework // since the game adds & removes its own handler on the fly. if (state.WindowSize.IsChanged) { - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}."); events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); @@ -828,7 +829,7 @@ namespace StardewModdingAPI.Framework // raise mouse wheel scrolled if (state.MouseWheelScroll.IsChanged) { - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}."); events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); } @@ -845,14 +846,14 @@ namespace StardewModdingAPI.Framework if (status == SButtonState.Pressed) { - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Events: button {button} pressed."); events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); } else if (status == SButtonState.Released) { - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Events: button {button} released."); events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); @@ -870,7 +871,7 @@ namespace StardewModdingAPI.Framework var was = state.ActiveMenu.Old; var now = state.ActiveMenu.New; - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}."); // raise menu events @@ -885,12 +886,12 @@ namespace StardewModdingAPI.Framework bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded // location list changes - if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || this.Monitor.IsVerbose)) + if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || verbose)) { var added = state.Locations.LocationList.Added.ToArray(); var removed = state.Locations.LocationList.Removed.ToArray(); - if (this.Monitor.IsVerbose) + if (verbose) { string addedText = added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; string removedText = removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; @@ -960,7 +961,7 @@ namespace StardewModdingAPI.Framework // raise current location changed if (playerState.Location.IsChanged) { - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Context: set location to {playerState.Location.New}."); events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old!, playerState.Location.New!)); @@ -972,7 +973,7 @@ namespace StardewModdingAPI.Framework if (!value.IsChanged) continue; - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}."); events.LevelChanged.Raise(new LevelChangedEventArgs(player, skill, value.Old, value.New)); @@ -983,7 +984,7 @@ namespace StardewModdingAPI.Framework { SnapshotItemListDiff inventory = playerState.Inventory; - if (this.Monitor.IsVerbose) + if (verbose) this.Monitor.Log("Events: player inventory changed."); events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged)); } From 077c897d53371d12c812be5145d917c59583d7cb Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 19:29:57 -0400 Subject: [PATCH 09/13] replace event.HasListeners() with property --- src/SMAPI/Framework/Events/IManagedEvent.cs | 3 +++ src/SMAPI/Framework/Events/ManagedEvent.cs | 11 +++++------ src/SMAPI/Framework/SCore.cs | 10 +++++----- src/SMAPI/Framework/SGame.cs | 4 ++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/SMAPI/Framework/Events/IManagedEvent.cs b/src/SMAPI/Framework/Events/IManagedEvent.cs index 0ae8c55a..55994c04 100644 --- a/src/SMAPI/Framework/Events/IManagedEvent.cs +++ b/src/SMAPI/Framework/Events/IManagedEvent.cs @@ -8,5 +8,8 @@ namespace StardewModdingAPI.Framework.Events *********/ /// A human-readable name for the event. string EventName { get; } + + /// Whether any handlers are listening to the event. + bool HasListeners { get; } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index a34106ce..8a3ca839 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -39,6 +39,9 @@ namespace StardewModdingAPI.Framework.Events /// public string EventName { get; } + /// + public bool HasListeners { get; private set; } + /********* ** Public methods @@ -52,12 +55,6 @@ namespace StardewModdingAPI.Framework.Events this.ModRegistry = modRegistry; } - /// Get whether anything is listening to the event. - public bool HasListeners() - { - return this.Handlers.Count > 0; - } - /// Add an event handler. /// The event handler. /// The mod which added the event handler. @@ -70,6 +67,7 @@ namespace StardewModdingAPI.Framework.Events this.Handlers.Add(managedHandler); this.CachedHandlers = null; + this.HasListeners = true; this.HasPriorities |= priority != EventPriority.Normal; } } @@ -88,6 +86,7 @@ namespace StardewModdingAPI.Framework.Events this.Handlers.RemoveAt(i); this.CachedHandlers = null; + this.HasListeners = this.Handlers.Count != 0; this.HasRemovedHandlers = true; break; } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 667fcf0f..c1e03634 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -886,7 +886,7 @@ namespace StardewModdingAPI.Framework bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded // location list changes - if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || verbose)) + if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners || verbose)) { var added = state.Locations.LocationList.Added.ToArray(); var removed = state.Locations.LocationList.Removed.ToArray(); @@ -929,7 +929,7 @@ namespace StardewModdingAPI.Framework events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed)); // chest items changed - if (events.ChestInventoryChanged.HasListeners()) + if (events.ChestInventoryChanged.HasListeners) { foreach (var pair in locState.ChestItems) { @@ -1077,7 +1077,7 @@ namespace StardewModdingAPI.Framework } // raise event - if (this.EventManager.LocaleChanged.HasListeners()) + if (this.EventManager.LocaleChanged.HasListeners) { this.EventManager.LocaleChanged.Raise( new LocaleChangedEventArgs( @@ -1140,7 +1140,7 @@ namespace StardewModdingAPI.Framework /// The asset name that was loaded. private void OnAssetLoaded(IContentManager contentManager, IAssetName assetName) { - if (this.EventManager.AssetReady.HasListeners()) + if (this.EventManager.AssetReady.HasListeners) this.EventManager.AssetReady.Raise(new AssetReadyEventArgs(assetName, assetName.GetBaseAssetName())); } @@ -1148,7 +1148,7 @@ namespace StardewModdingAPI.Framework /// The invalidated asset names. private void OnAssetsInvalidated(IList assetNames) { - if (this.EventManager.AssetsInvalidated.HasListeners()) + if (this.EventManager.AssetsInvalidated.HasListeners) this.EventManager.AssetsInvalidated.Raise(new AssetsInvalidatedEventArgs(assetNames, assetNames.Select(p => p.GetBaseAssetName()))); } diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 0a8a068f..38043e1c 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -353,7 +353,7 @@ namespace StardewModdingAPI.Framework } if (Game1.currentMinigame != null) { - if (events.Rendering.HasListeners()) + if (events.Rendering.HasListeners) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); events.Rendering.RaiseEmpty(); @@ -372,7 +372,7 @@ namespace StardewModdingAPI.Framework Game1.PushUIMode(); this.drawOverlays(Game1.spriteBatch); Game1.PopUIMode(); - if (events.Rendered.HasListeners()) + if (events.Rendered.HasListeners) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); events.Rendered.RaiseEmpty(); From 42a797a01240893e9a8e645253a269087b2d178d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 19:56:45 -0400 Subject: [PATCH 10/13] don't raise events that have no listeners This mainly avoids allocating event arg objects unnecessarily. --- src/SMAPI/Framework/InternalExtensions.cs | 3 +- src/SMAPI/Framework/SCore.cs | 167 +++++++++++++--------- src/SMAPI/Framework/SMultiplayer.cs | 12 +- 3 files changed, 111 insertions(+), 71 deletions(-) diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index 580651f3..ba9bbcec 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -64,7 +64,8 @@ namespace StardewModdingAPI.Framework /// The event to raise. public static void RaiseEmpty(this ManagedEvent @event) where TEventArgs : new() { - @event.Raise(Singleton.Instance); + if (@event.HasListeners) + @event.Raise(Singleton.Instance); } /**** diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index c1e03634..ec21e38a 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -47,6 +47,7 @@ using StardewModdingAPI.Toolkit.Utilities.PathLookups; using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.Menus; +using StardewValley.Objects; using xTile.Display; using LanguageCode = StardewValley.LocalizedContentManager.LanguageCode; using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix; @@ -808,7 +809,8 @@ namespace StardewModdingAPI.Framework if (verbose) this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}."); - events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); + if (events.WindowResized.HasListeners) + events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); } /********* @@ -823,7 +825,7 @@ namespace StardewModdingAPI.Framework ICursorPosition cursor = instance.Input.CursorPosition; // raise cursor moved event - if (state.Cursor.IsChanged) + if (state.Cursor.IsChanged && events.CursorMoved.HasListeners) events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old!, state.Cursor.New!)); // raise mouse wheel scrolled @@ -831,32 +833,42 @@ namespace StardewModdingAPI.Framework { if (verbose) this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}."); - events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); + + if (events.MouseWheelScrolled.HasListeners) + events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); } // raise input button events if (inputState.ButtonStates.Count > 0) { - events.ButtonsChanged.Raise(new ButtonsChangedEventArgs(cursor, inputState)); + if (events.ButtonsChanged.HasListeners) + events.ButtonsChanged.Raise(new ButtonsChangedEventArgs(cursor, inputState)); - foreach (var pair in inputState.ButtonStates) + bool raisePressed = events.ButtonPressed.HasListeners; + bool raiseReleased = events.ButtonReleased.HasListeners; + + if (verbose || raisePressed || raiseReleased) { - SButton button = pair.Key; - SButtonState status = pair.Value; - - if (status == SButtonState.Pressed) + foreach ((SButton button, SButtonState status) in inputState.ButtonStates) { - if (verbose) - this.Monitor.Log($"Events: button {button} pressed."); + switch (status) + { + case SButtonState.Pressed: + if (verbose) + this.Monitor.Log($"Events: button {button} pressed."); - events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); - } - else if (status == SButtonState.Released) - { - if (verbose) - this.Monitor.Log($"Events: button {button} released."); + if (raisePressed) + events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); + break; - events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + case SButtonState.Released: + if (verbose) + this.Monitor.Log($"Events: button {button} released."); + + if (raiseReleased) + events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + break; + } } } } @@ -868,14 +880,15 @@ namespace StardewModdingAPI.Framework *********/ if (state.ActiveMenu.IsChanged) { - var was = state.ActiveMenu.Old; - var now = state.ActiveMenu.New; + IClickableMenu? was = state.ActiveMenu.Old; + IClickableMenu? now = state.ActiveMenu.New; if (verbose) this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}."); // raise menu events - events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); + if (events.MenuChanged.HasListeners) + events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); } /********* @@ -898,7 +911,8 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText})."); } - events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); + if (events.LocationListChanged.HasListeners) + events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); } // raise location contents changed @@ -906,50 +920,47 @@ namespace StardewModdingAPI.Framework { foreach (LocationSnapshot locState in state.Locations.Locations) { - var location = locState.Location; + GameLocation location = locState.Location; // buildings changed - if (locState.Buildings.IsChanged) + if (locState.Buildings.IsChanged && events.BuildingListChanged.HasListeners) events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, locState.Buildings.Added, locState.Buildings.Removed)); // debris changed - if (locState.Debris.IsChanged) + if (locState.Debris.IsChanged && events.DebrisListChanged.HasListeners) events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, locState.Debris.Added, locState.Debris.Removed)); // large terrain features changed - if (locState.LargeTerrainFeatures.IsChanged) + if (locState.LargeTerrainFeatures.IsChanged && events.LargeTerrainFeatureListChanged.HasListeners) events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, locState.LargeTerrainFeatures.Added, locState.LargeTerrainFeatures.Removed)); // NPCs changed - if (locState.Npcs.IsChanged) + if (locState.Npcs.IsChanged && events.NpcListChanged.HasListeners) events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, locState.Npcs.Added, locState.Npcs.Removed)); // objects changed - if (locState.Objects.IsChanged) + if (locState.Objects.IsChanged && events.ObjectListChanged.HasListeners) 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)); - } + foreach ((Chest chest, SnapshotItemListDiff diff) in locState.ChestItems) + events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(chest, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged)); } // terrain features changed - if (locState.TerrainFeatures.IsChanged) + if (locState.TerrainFeatures.IsChanged && events.TerrainFeatureListChanged.HasListeners) events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); // furniture changed - if (locState.Furniture.IsChanged) + if (locState.Furniture.IsChanged && events.FurnitureListChanged.HasListeners) events.FurnitureListChanged.Raise(new FurnitureListChangedEventArgs(location, locState.Furniture.Added, locState.Furniture.Removed)); } } // raise time changed - if (raiseWorldEvents && state.Time.IsChanged) + if (raiseWorldEvents && state.Time.IsChanged && events.TimeChanged.HasListeners) events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); // raise player events @@ -964,29 +975,38 @@ namespace StardewModdingAPI.Framework if (verbose) this.Monitor.Log($"Context: set location to {playerState.Location.New}."); - events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old!, playerState.Location.New!)); + if (events.Warped.HasListeners) + events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old!, playerState.Location.New!)); } // raise player leveled up a skill - foreach ((SkillType skill, var value) in playerState.Skills) + bool raiseLevelChanged = events.LevelChanged.HasListeners; + if (verbose || raiseLevelChanged) { - if (!value.IsChanged) - continue; + foreach ((SkillType skill, var value) in playerState.Skills) + { + if (!value.IsChanged) + continue; - if (verbose) - this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}."); + if (verbose) + this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}."); - events.LevelChanged.Raise(new LevelChangedEventArgs(player, skill, value.Old, value.New)); + if (raiseLevelChanged) + events.LevelChanged.Raise(new LevelChangedEventArgs(player, skill, value.Old, value.New)); + } } // raise player inventory changed if (playerState.Inventory.IsChanged) { - SnapshotItemListDiff inventory = playerState.Inventory; - if (verbose) this.Monitor.Log("Events: player inventory changed."); - events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged)); + + if (events.InventoryChanged.HasListeners) + { + SnapshotItemListDiff inventory = playerState.Inventory; + events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged)); + } } } } @@ -998,7 +1018,9 @@ namespace StardewModdingAPI.Framework if (instance.IsFirstTick && !Context.IsGameLaunched) { Context.IsGameLaunched = true; - events.GameLaunched.Raise(new GameLaunchedEventArgs()); + + if (events.GameLaunched.HasListeners) + events.GameLaunched.Raise(new GameLaunchedEventArgs()); } // preloaded @@ -1124,9 +1146,11 @@ namespace StardewModdingAPI.Framework } // raise events - this.EventManager.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); + EventManager events = this.EventManager; + if (events.LoadStageChanged.HasListeners) + events.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); if (newStage == LoadStage.None) - this.EventManager.ReturnedToTitle.RaiseEmpty(); + events.ReturnedToTitle.RaiseEmpty(); } /// A callback invoked before runs. @@ -1156,9 +1180,14 @@ namespace StardewModdingAPI.Framework /// The asset info being requested. private IList RequestAssetOperations(IAssetInfo asset) { - List operations = new(); + // get event + var @event = this.EventManager.AssetRequested; + if (!@event.HasListeners) + return Array.Empty(); - this.EventManager.AssetRequested.Raise( + // get operations + List? operations = null; + @event.Raise( invoke: (mod, invoke) => { AssetRequestedEventArgs args = new(mod, asset, this.GetOnBehalfOfContentPack); @@ -1167,6 +1196,7 @@ namespace StardewModdingAPI.Framework if (args.LoadOperations.Any() || args.EditOperations.Any()) { + operations ??= new(); operations.Add( new AssetOperationGroup(mod, args.LoadOperations.ToArray(), args.EditOperations.ToArray()) ); @@ -1174,7 +1204,9 @@ namespace StardewModdingAPI.Framework } ); - return operations; + return operations != null + ? operations + : Array.Empty(); } /// Get the mod metadata for a content pack whose ID matches , if it's a valid content pack for the given . @@ -1226,23 +1258,26 @@ namespace StardewModdingAPI.Framework /// The message to deliver to applicable mods. private void OnModMessageReceived(ModMessageModel message) { - // get mod IDs to notify - HashSet modIDs = new HashSet(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase); - if (message.FromPlayerID == Game1.player?.UniqueMultiplayerID) - modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender + if (this.EventManager.ModMessageReceived.HasListeners) + { + // get mod IDs to notify + HashSet modIDs = new(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase); + if (message.FromPlayerID == Game1.player?.UniqueMultiplayerID) + modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender - // raise events - ModMessageReceivedEventArgs? args = null; - this.EventManager.ModMessageReceived.Raise( - invoke: (mod, invoke) => - { - if (modIDs.Contains(mod.Manifest.UniqueID)) + // raise events + ModMessageReceivedEventArgs? args = null; + this.EventManager.ModMessageReceived.Raise( + invoke: (mod, invoke) => { - args ??= new(message, this.Toolkit.JsonHelper); - invoke(args); + if (modIDs.Contains(mod.Manifest.UniqueID)) + { + args ??= new(message, this.Toolkit.JsonHelper); + invoke(args); + } } - } - ); + ); + } } /// Constructor a content manager to read game content files. diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 2badcbbf..441a50ef 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -235,7 +235,8 @@ namespace StardewModdingAPI.Framework } // raise event - this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(newPeer)); + if (this.EventManager.PeerContextReceived.HasListeners) + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(newPeer)); } break; @@ -259,7 +260,8 @@ namespace StardewModdingAPI.Framework resume(); // raise event - this.EventManager.PeerConnected.Raise(new PeerConnectedEventArgs(this.Peers[message.FarmerID])); + if (this.EventManager.PeerConnected.HasListeners) + this.EventManager.PeerConnected.Raise(new PeerConnectedEventArgs(this.Peers[message.FarmerID])); break; // handle mod message @@ -370,7 +372,9 @@ namespace StardewModdingAPI.Framework { this.Monitor.Log($"Player quit: {playerID}"); this.Peers.Remove(playerID); - this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer)); + + if (this.EventManager.PeerDisconnected.HasListeners) + this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer)); } } @@ -481,7 +485,7 @@ namespace StardewModdingAPI.Framework this.HostPeer = peer; // raise event - if (raiseEvent) + if (raiseEvent && this.EventManager.PeerContextReceived.HasListeners) this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(peer)); } From bbe5983acdd082d2185a69e2ad37d659a298223d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 21:36:45 -0400 Subject: [PATCH 11/13] rewrite asset operations to reduce allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • When raising AssetRequested, SMAPI now creates a single event args model and reuses it for each handler. • There's now a single AssetOperationGroup per asset, which tracks the loaders/editors registered by every mod for that asset. • The operation group's loader/editor lists are now used directly instead of querying them. --- docs/release-notes.md | 3 +- src/SMAPI/Events/AssetRequestedEventArgs.cs | 43 ++-- .../Framework/Content/AssetOperationGroup.cs | 7 +- src/SMAPI/Framework/ContentCoordinator.cs | 188 +++++++++--------- .../ContentManagers/GameContentManager.cs | 71 +++---- src/SMAPI/Framework/SCore.cs | 32 ++- 6 files changed, 168 insertions(+), 176 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index 7ccd466a..b35a4760 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -3,9 +3,10 @@ # Release notes ## Upcoming release * For players: - * Further improved performance in some cases. + * Reduced mods' in-game performance impact. * For mod authors: + * Refactored how event handling works under the hood, particularly the new content API. This should have no effect on mod usage. * Fixed error when loading a `.xnb` file through the old content API without the file extension. * Fixed asset propagation for player sprites not fully updating recolor masks in some cases. diff --git a/src/SMAPI/Events/AssetRequestedEventArgs.cs b/src/SMAPI/Events/AssetRequestedEventArgs.cs index d0aef1db..d6561028 100644 --- a/src/SMAPI/Events/AssetRequestedEventArgs.cs +++ b/src/SMAPI/Events/AssetRequestedEventArgs.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Events ** Fields *********/ /// The mod handling the event. - private readonly IModMetadata Mod; + private IModMetadata? Mod; /// Get the mod metadata for a content pack, if it's a valid content pack for the mod. private readonly Func GetOnBehalfOf; @@ -37,26 +37,31 @@ namespace StardewModdingAPI.Events public Type DataType => this.AssetInfo.DataType; /// The load operations requested by the event handler. - internal IList LoadOperations { get; } = new List(); + internal List LoadOperations { get; } = new(); /// The edit operations requested by the event handler. - internal IList EditOperations { get; } = new List(); + internal List EditOperations { get; } = new(); /********* ** Public methods *********/ /// Construct an instance. - /// The mod handling the event. /// The asset info being requested. /// Get the mod metadata for a content pack, if it's a valid content pack for the mod. - internal AssetRequestedEventArgs(IModMetadata mod, IAssetInfo assetInfo, Func getOnBehalfOf) + internal AssetRequestedEventArgs(IAssetInfo assetInfo, Func getOnBehalfOf) { - this.Mod = mod; this.AssetInfo = assetInfo; this.GetOnBehalfOf = getOnBehalfOf; } + /// Set the mod handling the event. + /// The mod handling the event. + internal void SetMod(IModMetadata mod) + { + this.Mod = mod; + } + /// Provide the initial instance for the asset, instead of trying to load it from the game's Content folder. /// Get the initial instance of an asset. /// If there are multiple loads that apply to the same asset, the priority with which this one should be applied. @@ -70,10 +75,11 @@ namespace StardewModdingAPI.Events /// public void LoadFrom(Func load, AssetLoadPriority priority, string? onBehalfOf = null) { + IModMetadata mod = this.GetMod(); this.LoadOperations.Add( new AssetLoadOperation( - Mod: this.Mod, - OnBehalfOf: this.GetOnBehalfOf(this.Mod, onBehalfOf, "load assets"), + Mod: mod, + OnBehalfOf: this.GetOnBehalfOf(mod, onBehalfOf, "load assets"), Priority: priority, GetData: _ => load() ) @@ -94,12 +100,13 @@ namespace StardewModdingAPI.Events public void LoadFromModFile(string relativePath, AssetLoadPriority priority) where TAsset : notnull { + IModMetadata mod = this.GetMod(); this.LoadOperations.Add( new AssetLoadOperation( - Mod: this.Mod, + Mod: mod, OnBehalfOf: null, Priority: priority, - GetData: _ => this.Mod.Mod!.Helper.ModContent.Load(relativePath) + GetData: _ => mod.Mod!.Helper.ModContent.Load(relativePath) ) ); } @@ -117,14 +124,26 @@ namespace StardewModdingAPI.Events /// public void Edit(Action apply, AssetEditPriority priority = AssetEditPriority.Default, string? onBehalfOf = null) { + IModMetadata mod = this.GetMod(); this.EditOperations.Add( new AssetEditOperation( - Mod: this.Mod, + Mod: mod, Priority: priority, - OnBehalfOf: this.GetOnBehalfOf(this.Mod, onBehalfOf, "edit assets"), + OnBehalfOf: this.GetOnBehalfOf(mod, onBehalfOf, "edit assets"), ApplyEdit: apply ) ); } + + + /********* + ** Private methods + *********/ + /// Get the mod handling the event. + /// This instance hasn't been initialized with the mod metadata yet. + private IModMetadata GetMod() + { + return this.Mod ?? throw new InvalidOperationException($"This {nameof(AssetRequestedEventArgs)} instance hasn't been initialized yet."); + } } } diff --git a/src/SMAPI/Framework/Content/AssetOperationGroup.cs b/src/SMAPI/Framework/Content/AssetOperationGroup.cs index 1566a8f0..11767d39 100644 --- a/src/SMAPI/Framework/Content/AssetOperationGroup.cs +++ b/src/SMAPI/Framework/Content/AssetOperationGroup.cs @@ -1,8 +1,9 @@ +using System.Collections.Generic; + namespace StardewModdingAPI.Framework.Content { - /// A set of operations to apply to an asset for a given or implementation. - /// The mod applying the changes. + /// A set of operations to apply to an asset. /// The load operations to apply. /// The edit operations to apply. - internal record AssetOperationGroup(IModMetadata Mod, AssetLoadOperation[] LoadOperations, AssetEditOperation[] EditOperations); + internal record AssetOperationGroup(List LoadOperations, List EditOperations); } diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 6702f5e6..a24581a0 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework private readonly Action> OnAssetsInvalidated; /// Get the load/edit operations to apply to an asset by querying registered event handlers. - private readonly Func> RequestAssetOperations; + private readonly Func RequestAssetOperations; /// The loaded content managers (including the ). private readonly List ContentManagers = new(); @@ -79,15 +79,15 @@ namespace StardewModdingAPI.Framework private Lazy> LocaleCodes; /// The cached asset load/edit operations to apply, indexed by asset name. - private readonly TickCacheDictionary> AssetOperationsByKey = new(); + private readonly TickCacheDictionary AssetOperationsByKey = new(); /// A cache of asset operation groups created for legacy implementations. [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly Dictionary> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); /// A cache of asset operation groups created for legacy implementations. [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly Dictionary> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); /********* @@ -126,7 +126,7 @@ namespace StardewModdingAPI.Framework /// Get a file lookup for the given directory. /// A callback to invoke when any asset names have been invalidated from the cache. /// Get the load/edit operations to apply to an asset by querying registered event handlers. - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action onAssetLoaded, Func getFileLookup, Action> onAssetsInvalidated, Func> requestAssetOperations) + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action onAssetLoaded, Func getFileLookup, Action> onAssetsInvalidated, Func requestAssetOperations) { this.GetFileLookup = getFileLookup; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); @@ -449,16 +449,12 @@ namespace StardewModdingAPI.Framework /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now. /// The asset type. /// The asset info to load or edit. - public IList GetAssetOperations(IAssetInfo info) + public AssetOperationGroup? GetAssetOperations(IAssetInfo info) where T : notnull { return this.AssetOperationsByKey.GetOrSet( info.Name, -#pragma warning disable CS0612, CS0618 // deprecated code - () => this.Editors.Count > 0 || this.Loaders.Count > 0 - ? this.GetAssetOperationsIncludingLegacyWithoutCache(info).ToArray() -#pragma warning restore CS0612, CS0618 - : this.RequestAssetOperations(info) + () => this.GetAssetOperationsWithoutCache(info) ); } @@ -584,41 +580,40 @@ namespace StardewModdingAPI.Framework /// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the cache. /// The asset type. /// The asset info to load or edit. - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private IEnumerable GetAssetOperationsIncludingLegacyWithoutCache(IAssetInfo info) + private AssetOperationGroup? GetAssetOperationsWithoutCache(IAssetInfo info) where T : notnull { - IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); - // new content API - foreach (AssetOperationGroup group in this.RequestAssetOperations(info)) - yield return group; + AssetOperationGroup? group = this.RequestAssetOperations(info); // legacy load operations - foreach (ModLinked loader in this.Loaders) +#pragma warning disable CS0612, CS0618 // deprecated code + if (this.Editors.Count > 0 || this.Loaders.Count > 0) { - // check if loader applies - try - { - if (!loader.Data.CanLoad(legacyInfo)) - continue; - } - catch (Exception ex) - { - loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } + IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); - // add operation - yield return this.GetOrCreateLegacyOperationGroup( - cache: this.LegacyLoaderCache, - editor: loader.Data, - dataType: info.DataType, - createGroup: () => new AssetOperationGroup( - Mod: loader.Mod, - LoadOperations: new[] - { - new AssetLoadOperation( + foreach (ModLinked loader in this.Loaders) + { + // check if loader applies + try + { + if (!loader.Data.CanLoad(legacyInfo)) + continue; + } + catch (Exception ex) + { + loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // add operation + group ??= new AssetOperationGroup(new List(), new List()); + group.LoadOperations.Add( + this.GetOrCreateLegacyOperation( + cache: this.LegacyLoaderCache, + editor: loader.Data, + dataType: info.DataType, + create: () => new AssetLoadOperation( Mod: loader.Mod, OnBehalfOf: null, Priority: AssetLoadPriority.Exclusive, @@ -626,59 +621,54 @@ namespace StardewModdingAPI.Framework this.GetLegacyAssetInfo(assetInfo) ) ) - }, - EditOperations: Array.Empty() - ) - ); - } + ) + ); + } - // legacy edit operations - foreach (var editor in this.Editors) - { - // check if editor applies - try + // legacy edit operations + foreach (var editor in this.Editors) { - if (!editor.Data.CanEdit(legacyInfo)) + // check if editor applies + try + { + if (!editor.Data.CanEdit(legacyInfo)) + continue; + } + catch (Exception ex) + { + editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; - } - catch (Exception ex) - { - editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } + } - // HACK - // - // If two editors have the same priority, they're applied in registration order (so - // whichever was registered first is applied first). Mods often depend on this - // behavior, like Json Assets registering its interceptors before Content Patcher. - // - // Unfortunately the old & new content APIs have separate lists, so new-API - // interceptors always ran before old-API interceptors with the same priority, - // regardless of the registration order *between* APIs. Since the new API works in - // a fundamentally different way (i.e. loads/edits are defined on asset request - // instead of by registering a global 'editor' or 'loader' class), there's no way - // to track registration order between them. - // - // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for - // specific legacy editors to maintain compatibility. - AssetEditPriority priority = editor.Data.GetType().FullName switch - { - "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher - _ => AssetEditPriority.Default - }; + // HACK + // + // If two editors have the same priority, they're applied in registration order (so + // whichever was registered first is applied first). Mods often depend on this + // behavior, like Json Assets registering its interceptors before Content Patcher. + // + // Unfortunately the old & new content APIs have separate lists, so new-API + // interceptors always ran before old-API interceptors with the same priority, + // regardless of the registration order *between* APIs. Since the new API works in + // a fundamentally different way (i.e. loads/edits are defined on asset request + // instead of by registering a global 'editor' or 'loader' class), there's no way + // to track registration order between them. + // + // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for + // specific legacy editors to maintain compatibility. + AssetEditPriority priority = editor.Data.GetType().FullName switch + { + "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher + _ => AssetEditPriority.Default + }; - // add operation - yield return this.GetOrCreateLegacyOperationGroup( - cache: this.LegacyEditorCache, - editor: editor.Data, - dataType: info.DataType, - createGroup: () => new AssetOperationGroup( - Mod: editor.Mod, - LoadOperations: Array.Empty(), - EditOperations: new[] - { - new AssetEditOperation( + // add operation + group ??= new AssetOperationGroup(new List(), new List()); + group.EditOperations.Add( + this.GetOrCreateLegacyOperation( + cache: this.LegacyEditorCache, + editor: editor.Data, + dataType: info.DataType, + create: () => new AssetEditOperation( Mod: editor.Mod, OnBehalfOf: null, Priority: priority, @@ -686,28 +676,32 @@ namespace StardewModdingAPI.Framework this.GetLegacyAssetData(assetData) ) ) - } - ) - ); + ) + ); + } } +#pragma warning restore CS0612, CS0618 + + return group; } /// Get a cached asset operation group for a legacy or instance, creating it if needed. /// The editor type (one of or ). + /// The operation model type. /// The cached operation groups for the interceptor type. /// The legacy asset interceptor. /// The asset data type. - /// Create the asset operation group if it's not cached yet. - private AssetOperationGroup GetOrCreateLegacyOperationGroup(Dictionary> cache, TInterceptor editor, Type dataType, Func createGroup) + /// Create the asset operation group if it's not cached yet. + private TOperation GetOrCreateLegacyOperation(Dictionary> cache, TInterceptor editor, Type dataType, Func create) where TInterceptor : class { - if (!cache.TryGetValue(editor, out Dictionary? cacheByType)) - cache[editor] = cacheByType = new Dictionary(); + if (!cache.TryGetValue(editor, out Dictionary? cacheByType)) + cache[editor] = cacheByType = new Dictionary(); - if (!cacheByType.TryGetValue(dataType, out AssetOperationGroup? group)) - cacheByType[dataType] = group = createGroup(); + if (!cacheByType.TryGetValue(dataType, out TOperation? operation)) + cacheByType[dataType] = operation = create(); - return group; + return operation; } /// Get an asset info compatible with legacy and instances, which always expect the base name. diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index c53040e1..2aa50542 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -75,15 +75,19 @@ namespace StardewModdingAPI.Framework.ContentManagers // custom asset from a loader string locale = this.GetLocale(); IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); - AssetLoadOperation[] loaders = this.GetLoaders(info).ToArray(); - - if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error)) + AssetOperationGroup? operations = this.Coordinator.GetAssetOperations(info); + if (operations?.LoadOperations.Count > 0) { - this.Monitor.Log(error, LogLevel.Warn); - return false; + if (!this.AssertMaxOneRequiredLoader(info, operations.LoadOperations, out string? error)) + { + this.Monitor.Log(error, LogLevel.Warn); + return false; + } + + return true; } - return loaders.Any(); + return false; } /// @@ -121,10 +125,11 @@ namespace StardewModdingAPI.Framework.ContentManagers data = this.AssetsBeingLoaded.Track(assetName.Name, () => { IAssetInfo info = new AssetInfo(assetName.LocaleCode, assetName, typeof(T), this.AssertAndNormalizeAssetName); + AssetOperationGroup? operations = this.Coordinator.GetAssetOperations(info); IAssetData asset = - this.ApplyLoader(info) + this.ApplyLoader(info, operations?.LoadOperations) ?? new AssetDataForObject(info, this.RawLoad(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection); - asset = this.ApplyEditors(info, asset); + asset = this.ApplyEditors(info, asset, operations?.EditOperations); return (T)asset.Data; }); } @@ -149,25 +154,23 @@ namespace StardewModdingAPI.Framework.ContentManagers *********/ /// Load the initial asset from the registered loaders. /// The basic asset metadata. + /// The load operations to apply to the asset. /// Returns the loaded asset metadata, or null if no loader matched. - private IAssetData? ApplyLoader(IAssetInfo info) + private IAssetData? ApplyLoader(IAssetInfo info, List? loadOperations) where T : notnull { // find matching loader - AssetLoadOperation? loader; + AssetLoadOperation? loader = null; + if (loadOperations?.Count > 0) { - AssetLoadOperation[] loaders = this.GetLoaders(info).OrderByDescending(p => p.Priority).ToArray(); - - if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error)) + if (!this.AssertMaxOneRequiredLoader(info, loadOperations, out string? error)) { this.Monitor.Log(error, LogLevel.Warn); return null; } - loader = loaders.FirstOrDefault(); + loader = loadOperations.OrderByDescending(p => p.Priority).FirstOrDefault(); } - - // no loader found if (loader == null) return null; @@ -195,9 +198,13 @@ namespace StardewModdingAPI.Framework.ContentManagers /// The asset type. /// The basic asset metadata. /// The loaded asset. - private IAssetData ApplyEditors(IAssetInfo info, IAssetData asset) + /// The edit operations to apply to the asset. + private IAssetData ApplyEditors(IAssetInfo info, IAssetData asset, List? editOperations) where T : notnull { + if (editOperations?.Count is not > 0) + return asset; + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection); // special case: if the asset was loaded with a more general type like 'object', call editors with the actual type instead. @@ -210,12 +217,12 @@ namespace StardewModdingAPI.Framework.ContentManagers return (IAssetData)this.GetType() .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(actualType) - .Invoke(this, new object[] { info, asset })!; + .Invoke(this, new object[] { info, asset, editOperations })!; } } // edit asset - AssetEditOperation[] editors = this.GetEditors(info).OrderBy(p => p.Priority).ToArray(); + AssetEditOperation[] editors = editOperations.OrderBy(p => p.Priority).ToArray(); foreach (AssetEditOperation editor in editors) { IModMetadata mod = editor.Mod; @@ -250,34 +257,12 @@ namespace StardewModdingAPI.Framework.ContentManagers return asset; } - /// Get the asset loaders which handle an asset. - /// The asset type. - /// The basic asset metadata. - private IEnumerable GetLoaders(IAssetInfo info) - where T : notnull - { - return this.Coordinator - .GetAssetOperations(info) - .SelectMany(p => p.LoadOperations); - } - - /// Get the asset editors to apply to an asset. - /// The asset type. - /// The basic asset metadata. - private IEnumerable GetEditors(IAssetInfo info) - where T : notnull - { - return this.Coordinator - .GetAssetOperations(info) - .SelectMany(p => p.EditOperations); - } - /// Assert that at most one loader will be applied to an asset. /// The basic asset metadata. /// The asset loaders to apply. /// The error message to show to the user, if the method returns false. /// Returns true if only one loader will apply, else false. - private bool AssertMaxOneRequiredLoader(IAssetInfo info, AssetLoadOperation[] loaders, [NotNullWhen(false)] out string? error) + private bool AssertMaxOneRequiredLoader(IAssetInfo info, List loaders, [NotNullWhen(false)] out string? error) { AssetLoadOperation[] required = loaders.Where(p => p.Priority == AssetLoadPriority.Exclusive).ToArray(); if (required.Length <= 1) @@ -295,7 +280,7 @@ namespace StardewModdingAPI.Framework.ContentManagers ? $"Multiple mods want to provide the '{info.Name}' asset: {string.Join(", ", loaderNames)}" : $"The '{loaderNames[0]}' mod wants to provide the '{info.Name}' asset multiple times"; - error = $"{errorPhrase}. An asset can't be loaded multiple times, so SMAPI will use the default asset instead. Uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)"; + error = $"{errorPhrase}. An asset can't be loaded multiple times, so SMAPI will use the default asset instead. Uninstall one of the mods to fix this. (Message for modders: you should avoid {nameof(AssetLoadPriority)}.{nameof(AssetLoadPriority.Exclusive)} and {nameof(IAssetLoader)} if possible to avoid conflicts.)"; return false; } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index ec21e38a..41b975e8 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1178,35 +1178,27 @@ namespace StardewModdingAPI.Framework /// Get the load/edit operations to apply to an asset by querying registered event handlers. /// The asset info being requested. - private IList RequestAssetOperations(IAssetInfo asset) + private AssetOperationGroup? RequestAssetOperations(IAssetInfo asset) { // get event - var @event = this.EventManager.AssetRequested; - if (!@event.HasListeners) - return Array.Empty(); + var requestedEvent = this.EventManager.AssetRequested; + if (!requestedEvent.HasListeners) + return null; - // get operations - List? operations = null; - @event.Raise( + // raise event + AssetRequestedEventArgs args = new(asset, this.GetOnBehalfOfContentPack); + requestedEvent.Raise( invoke: (mod, invoke) => { - AssetRequestedEventArgs args = new(mod, asset, this.GetOnBehalfOfContentPack); - + args.SetMod(mod); invoke(args); - - if (args.LoadOperations.Any() || args.EditOperations.Any()) - { - operations ??= new(); - operations.Add( - new AssetOperationGroup(mod, args.LoadOperations.ToArray(), args.EditOperations.ToArray()) - ); - } } ); - return operations != null - ? operations - : Array.Empty(); + // collect operations + return args.LoadOperations.Count != 0 || args.EditOperations.Count != 0 + ? new AssetOperationGroup(args.LoadOperations, args.EditOperations) + : null; } /// Get the mod metadata for a content pack whose ID matches , if it's a valid content pack for the given . From 5a7422b3122dc0c46c6fe9b445eae0bc5df77298 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 11 May 2022 22:32:33 -0400 Subject: [PATCH 12/13] log time change in verbose mode --- docs/release-notes.md | 1 + src/SMAPI/Framework/SCore.cs | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index b35a4760..53711454 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -7,6 +7,7 @@ * For mod authors: * Refactored how event handling works under the hood, particularly the new content API. This should have no effect on mod usage. + * In-game time changes are now logged in verbose mod. * Fixed error when loading a `.xnb` file through the old content API without the file extension. * Fixed asset propagation for player sprites not fully updating recolor masks in some cases. diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 41b975e8..5ae4fdbb 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -960,8 +960,14 @@ namespace StardewModdingAPI.Framework } // raise time changed - if (raiseWorldEvents && state.Time.IsChanged && events.TimeChanged.HasListeners) - events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); + if (raiseWorldEvents && state.Time.IsChanged) + { + if (verbose) + this.Monitor.Log($"Context: time changed to {state.Time.New}."); + + if (events.TimeChanged.HasListeners) + events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); + } // raise player events if (raiseWorldEvents) From e943ae84136d46432e04e577041850d2aa7db43e Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 12 May 2022 00:21:46 -0400 Subject: [PATCH 13/13] prepare for release --- build/common.targets | 2 +- docs/release-notes.md | 8 +++++--- src/SMAPI.Mods.ConsoleCommands/manifest.json | 4 ++-- src/SMAPI.Mods.ErrorHandler/manifest.json | 4 ++-- src/SMAPI.Mods.SaveBackup/manifest.json | 4 ++-- src/SMAPI/Constants.cs | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/build/common.targets b/build/common.targets index 0436ed5b..a7db917c 100644 --- a/build/common.targets +++ b/build/common.targets @@ -1,7 +1,7 @@ - 3.14.2 + 3.14.3 SMAPI latest $(AssemblySearchPaths);{GAC} diff --git a/docs/release-notes.md b/docs/release-notes.md index 53711454..6311d7dc 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,13 +1,15 @@ ← [README](README.md) # Release notes -## Upcoming release +## 3.14.3 +Released 12 May 2022 for Stardew Valley 1.5.6 or later. + * For players: - * Reduced mods' in-game performance impact. + * Reduced in-game performance impact. * For mod authors: * Refactored how event handling works under the hood, particularly the new content API. This should have no effect on mod usage. - * In-game time changes are now logged in verbose mod. + * Verbose mode now logs the in-game time. * Fixed error when loading a `.xnb` file through the old content API without the file extension. * Fixed asset propagation for player sprites not fully updating recolor masks in some cases. diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index c263456a..0e2b023d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.14.2", + "Version": "3.14.3", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.14.2" + "MinimumApiVersion": "3.14.3" } diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index 6e6a271f..f449b3bd 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.14.2", + "Version": "3.14.3", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.14.2" + "MinimumApiVersion": "3.14.3" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 5ba91568..23e241b5 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.14.2", + "Version": "3.14.3", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.14.2" + "MinimumApiVersion": "3.14.3" } diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index a289ce4b..ef729f4f 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -50,7 +50,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// SMAPI's current raw semantic version. - internal static string RawApiVersion = "3.14.2"; + internal static string RawApiVersion = "3.14.3"; } /// Contains SMAPI's constants and assumptions.