diff options
26 files changed, 483 insertions, 336 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 @@ <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <!--set general build properties --> - <Version>3.14.2</Version> + <Version>3.14.3</Version> <Product>SMAPI</Product> <LangVersion>latest</LangVersion> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> diff --git a/docs/release-notes.md b/docs/release-notes.md index 98747613..6311d7dc 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,21 @@ ← [README](README.md) # Release notes +## 3.14.3 +Released 12 May 2022 for Stardew Valley 1.5.6 or later. + +* For players: + * 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. + * 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. + +* 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.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.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.<section value>.name' and '.description' to add a translated name & tooltip.", + "type": "string" + }, "additionalProperties": false }, 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; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.14.2"; + internal static string RawApiVersion = "3.14.3"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> 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 *********/ /// <summary>The mod handling the event.</summary> - private readonly IModMetadata Mod; + private IModMetadata? Mod; /// <summary>Get the mod metadata for a content pack, if it's a valid content pack for the mod.</summary> private readonly Func<IModMetadata, string?, string, IModMetadata?> GetOnBehalfOf; @@ -37,26 +37,31 @@ namespace StardewModdingAPI.Events public Type DataType => this.AssetInfo.DataType; /// <summary>The load operations requested by the event handler.</summary> - internal IList<AssetLoadOperation> LoadOperations { get; } = new List<AssetLoadOperation>(); + internal List<AssetLoadOperation> LoadOperations { get; } = new(); /// <summary>The edit operations requested by the event handler.</summary> - internal IList<AssetEditOperation> EditOperations { get; } = new List<AssetEditOperation>(); + internal List<AssetEditOperation> EditOperations { get; } = new(); /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="mod">The mod handling the event.</param> /// <param name="assetInfo">The asset info being requested.</param> /// <param name="getOnBehalfOf">Get the mod metadata for a content pack, if it's a valid content pack for the mod.</param> - internal AssetRequestedEventArgs(IModMetadata mod, IAssetInfo assetInfo, Func<IModMetadata, string?, string, IModMetadata?> getOnBehalfOf) + internal AssetRequestedEventArgs(IAssetInfo assetInfo, Func<IModMetadata, string?, string, IModMetadata?> getOnBehalfOf) { - this.Mod = mod; this.AssetInfo = assetInfo; this.GetOnBehalfOf = getOnBehalfOf; } + /// <summary>Set the mod handling the event.</summary> + /// <param name="mod">The mod handling the event.</param> + internal void SetMod(IModMetadata mod) + { + this.Mod = mod; + } + /// <summary>Provide the initial instance for the asset, instead of trying to load it from the game's <c>Content</c> folder.</summary> /// <param name="load">Get the initial instance of an asset.</param> /// <param name="priority">If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</param> @@ -70,10 +75,11 @@ namespace StardewModdingAPI.Events /// </remarks> public void LoadFrom(Func<object> 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<TAsset>(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<TAsset>(relativePath) + GetData: _ => mod.Mod!.Helper.ModContent.Load<TAsset>(relativePath) ) ); } @@ -117,14 +124,26 @@ namespace StardewModdingAPI.Events /// </remarks> public void Edit(Action<IAssetData> 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 + *********/ + /// <summary>Get the mod handling the event.</summary> + /// <exception cref="InvalidOperationException">This instance hasn't been initialized with the mod metadata yet.</exception> + 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 { - /// <summary>A set of operations to apply to an asset for a given <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> implementation.</summary> - /// <param name="Mod">The mod applying the changes.</param> + /// <summary>A set of operations to apply to an asset.</summary> /// <param name="LoadOperations">The load operations to apply.</param> /// <param name="EditOperations">The edit operations to apply.</param> - internal record AssetOperationGroup(IModMetadata Mod, AssetLoadOperation[] LoadOperations, AssetEditOperation[] EditOperations); + internal record AssetOperationGroup(List<AssetLoadOperation> LoadOperations, List<AssetEditOperation> 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<IList<IAssetName>> OnAssetsInvalidated; /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary> - private readonly Func<IAssetInfo, IList<AssetOperationGroup>> RequestAssetOperations; + private readonly Func<IAssetInfo, AssetOperationGroup?> RequestAssetOperations; /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> private readonly List<IContentManager> ContentManagers = new(); @@ -79,15 +79,15 @@ namespace StardewModdingAPI.Framework private Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; /// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary> - private readonly TickCacheDictionary<IAssetName, IList<AssetOperationGroup>> AssetOperationsByKey = new(); + private readonly TickCacheDictionary<IAssetName, AssetOperationGroup?> AssetOperationsByKey = new(); /// <summary>A cache of asset operation groups created for legacy <see cref="IAssetLoader"/> implementations.</summary> [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly Dictionary<IAssetLoader, Dictionary<Type, AssetOperationGroup>> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary<IAssetLoader, Dictionary<Type, AssetLoadOperation>> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance); /// <summary>A cache of asset operation groups created for legacy <see cref="IAssetEditor"/> implementations.</summary> [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private readonly Dictionary<IAssetEditor, Dictionary<Type, AssetOperationGroup>> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary<IAssetEditor, Dictionary<Type, AssetEditOperation>> LegacyEditorCache = new(ReferenceEqualityComparer.Instance); /********* @@ -126,7 +126,7 @@ namespace StardewModdingAPI.Framework /// <param name="getFileLookup">Get a file lookup for the given directory.</param> /// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param> /// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, IList<AssetOperationGroup>> requestAssetOperations) + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, Func<string, IFileLookup> getFileLookup, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, AssetOperationGroup?> requestAssetOperations) { this.GetFileLookup = getFileLookup; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); @@ -449,16 +449,12 @@ namespace StardewModdingAPI.Framework /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="info">The asset info to load or edit.</param> - public IList<AssetOperationGroup> GetAssetOperations<T>(IAssetInfo info) + public AssetOperationGroup? GetAssetOperations<T>(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<T>(info).ToArray() -#pragma warning restore CS0612, CS0618 - : this.RequestAssetOperations(info) + () => this.GetAssetOperationsWithoutCache<T>(info) ); } @@ -584,41 +580,40 @@ namespace StardewModdingAPI.Framework /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the <see cref="AssetOperationsByKey"/> cache.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="info">The asset info to load or edit.</param> - [Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")] - private IEnumerable<AssetOperationGroup> GetAssetOperationsIncludingLegacyWithoutCache<T>(IAssetInfo info) + private AssetOperationGroup? GetAssetOperationsWithoutCache<T>(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<IAssetLoader> 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 + IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); + + foreach (ModLinked<IAssetLoader> loader in this.Loaders) { - if (!loader.Data.CanLoad<T>(legacyInfo)) + // check if loader applies + try + { + if (!loader.Data.CanLoad<T>(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; - } - 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 - yield return this.GetOrCreateLegacyOperationGroup( - cache: this.LegacyLoaderCache, - editor: loader.Data, - dataType: info.DataType, - createGroup: () => new AssetOperationGroup( - Mod: loader.Mod, - LoadOperations: new[] - { - new AssetLoadOperation( + // add operation + group ??= new AssetOperationGroup(new List<AssetLoadOperation>(), new List<AssetEditOperation>()); + 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<AssetEditOperation>() - ) - ); - } + ) + ); + } - // 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<T>(legacyInfo)) + // check if editor applies + try + { + if (!editor.Data.CanEdit<T>(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 - }; - - // add operation - yield return this.GetOrCreateLegacyOperationGroup( - cache: this.LegacyEditorCache, - editor: editor.Data, - dataType: info.DataType, - createGroup: () => new AssetOperationGroup( - Mod: editor.Mod, - LoadOperations: Array.Empty<AssetLoadOperation>(), - EditOperations: new[] - { - new AssetEditOperation( + // 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 + group ??= new AssetOperationGroup(new List<AssetLoadOperation>(), new List<AssetEditOperation>()); + 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; } /// <summary>Get a cached asset operation group for a legacy <see cref="IAssetLoader"/> or <see cref="IAssetEditor"/> instance, creating it if needed.</summary> /// <typeparam name="TInterceptor">The editor type (one of <see cref="IAssetLoader"/> or <see cref="IAssetEditor"/>).</typeparam> + /// <typeparam name="TOperation">The operation model type.</typeparam> /// <param name="cache">The cached operation groups for the interceptor type.</param> /// <param name="editor">The legacy asset interceptor.</param> /// <param name="dataType">The asset data type.</param> - /// <param name="createGroup">Create the asset operation group if it's not cached yet.</param> - private AssetOperationGroup GetOrCreateLegacyOperationGroup<TInterceptor>(Dictionary<TInterceptor, Dictionary<Type, AssetOperationGroup>> cache, TInterceptor editor, Type dataType, Func<AssetOperationGroup> createGroup) + /// <param name="create">Create the asset operation group if it's not cached yet.</param> + private TOperation GetOrCreateLegacyOperation<TInterceptor, TOperation>(Dictionary<TInterceptor, Dictionary<Type, TOperation>> cache, TInterceptor editor, Type dataType, Func<TOperation> create) where TInterceptor : class { - if (!cache.TryGetValue(editor, out Dictionary<Type, AssetOperationGroup>? cacheByType)) - cache[editor] = cacheByType = new Dictionary<Type, AssetOperationGroup>(); + if (!cache.TryGetValue(editor, out Dictionary<Type, TOperation>? cacheByType)) + cache[editor] = cacheByType = new Dictionary<Type, TOperation>(); - 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; } /// <summary>Get an asset info compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> 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/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<object>(info).ToArray(); - - if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error)) + AssetOperationGroup? operations = this.Coordinator.GetAssetOperations<object>(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; } /// <inheritdoc /> @@ -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<T>(info); IAssetData asset = - this.ApplyLoader<T>(info) + this.ApplyLoader<T>(info, operations?.LoadOperations) ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection); - asset = this.ApplyEditors<T>(info, asset); + asset = this.ApplyEditors<T>(info, asset, operations?.EditOperations); return (T)asset.Data; }); } @@ -149,25 +154,23 @@ namespace StardewModdingAPI.Framework.ContentManagers *********/ /// <summary>Load the initial asset from the registered loaders.</summary> /// <param name="info">The basic asset metadata.</param> + /// <param name="loadOperations">The load operations to apply to the asset.</param> /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> - private IAssetData? ApplyLoader<T>(IAssetInfo info) + private IAssetData? ApplyLoader<T>(IAssetInfo info, List<AssetLoadOperation>? loadOperations) where T : notnull { // find matching loader - AssetLoadOperation? loader; + AssetLoadOperation? loader = null; + if (loadOperations?.Count > 0) { - AssetLoadOperation[] loaders = this.GetLoaders<T>(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 /// <typeparam name="T">The asset type.</typeparam> /// <param name="info">The basic asset metadata.</param> /// <param name="asset">The loaded asset.</param> - private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) + /// <param name="editOperations">The edit operations to apply to the asset.</param> + private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset, List<AssetEditOperation>? 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<T>(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; } - /// <summary>Get the asset loaders which handle an asset.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="info">The basic asset metadata.</param> - private IEnumerable<AssetLoadOperation> GetLoaders<T>(IAssetInfo info) - where T : notnull - { - return this.Coordinator - .GetAssetOperations<T>(info) - .SelectMany(p => p.LoadOperations); - } - - /// <summary>Get the asset editors to apply to an asset.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="info">The basic asset metadata.</param> - private IEnumerable<AssetEditOperation> GetEditors<T>(IAssetInfo info) - where T : notnull - { - return this.Coordinator - .GetAssetOperations<T>(info) - .SelectMany(p => p.EditOperations); - } - /// <summary>Assert that at most one loader will be applied to an asset.</summary> /// <param name="info">The basic asset metadata.</param> /// <param name="loaders">The asset loaders to apply.</param> /// <param name="error">The error message to show to the user, if the method returns false.</param> /// <returns>Returns true if only one loader will apply, else false.</returns> - private bool AssertMaxOneRequiredLoader(IAssetInfo info, AssetLoadOperation[] loaders, [NotNullWhen(false)] out string? error) + private bool AssertMaxOneRequiredLoader(IAssetInfo info, List<AssetLoadOperation> 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/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<T>(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<T>(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 /// <param name="file">The file to load.</param> private T HandleUnknownFileType<T>(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'."); } /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary> + /// <param name="errorType">Why loading an asset through the content pipeline failed.</param> /// <param name="assetName">The asset name that failed to load.</param> /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param> /// <param name="exception">The underlying exception, if applicable.</param> - 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); } /// <summary>Get a file from the mod folder.</summary> @@ -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/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<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false) + ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) { - return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, isPerformanceCritical); + return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry); } // init events @@ -210,21 +210,21 @@ namespace StardewModdingAPI.Framework.Events this.LocaleChanged = ManageEventOf<LocaleChangedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.LocaleChanged)); this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); - this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true); - this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true); - this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld), isPerformanceCritical: true); - this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld), isPerformanceCritical: true); - this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu), isPerformanceCritical: true); - this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu), isPerformanceCritical: true); - this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud), isPerformanceCritical: true); - this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud), isPerformanceCritical: true); + this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering)); + this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered)); + this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld)); + this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld)); + this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu)); + this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu)); + this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud)); + this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud)); this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized)); this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched)); - this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking), isPerformanceCritical: true); - this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked), isPerformanceCritical: true); - this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking), isPerformanceCritical: true); - this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked), isPerformanceCritical: true); + this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking)); + this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked)); + this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking)); + this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked)); this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating)); this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated)); this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving)); @@ -238,7 +238,7 @@ namespace StardewModdingAPI.Framework.Events this.ButtonsChanged = ManageEventOf<ButtonsChangedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonsChanged)); this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); - this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true); + this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved)); this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived)); @@ -261,8 +261,8 @@ namespace StardewModdingAPI.Framework.Events this.FurnitureListChanged = ManageEventOf<FurnitureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.FurnitureListChanged)); this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); - this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true); - this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true); + this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking)); + this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked)); } } } diff --git a/src/SMAPI/Framework/Events/IManagedEvent.cs b/src/SMAPI/Framework/Events/IManagedEvent.cs index e4e3ca08..55994c04 100644 --- a/src/SMAPI/Framework/Events/IManagedEvent.cs +++ b/src/SMAPI/Framework/Events/IManagedEvent.cs @@ -9,7 +9,7 @@ namespace StardewModdingAPI.Framework.Events /// <summary>A human-readable name for the event.</summary> string EventName { get; } - /// <summary>Whether the event is typically called at least once per second.</summary> - bool IsPerformanceCritical { get; } + /// <summary>Whether any handlers are listening to the event.</summary> + bool HasListeners { get; } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index 4b8a770d..8a3ca839 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -20,14 +20,17 @@ namespace StardewModdingAPI.Framework.Events /// <summary>The underlying event handlers.</summary> private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new(); - /// <summary>A cached snapshot of <see cref="Handlers"/>, or <c>null</c> to rebuild it next raise.</summary> + /// <summary>A cached snapshot of the <see cref="Handlers"/> sorted by event priority, or <c>null</c> to rebuild it next raise.</summary> private ManagedEventHandler<TEventArgs>[]? CachedHandlers = Array.Empty<ManagedEventHandler<TEventArgs>>(); /// <summary>The total number of event handlers registered for this events, regardless of whether they're still registered.</summary> private int RegistrationIndex; - /// <summary>Whether new handlers were added since the last raise.</summary> - private bool HasNewHandlers; + /// <summary>Whether handlers were removed since the last raise.</summary> + private bool HasRemovedHandlers; + + /// <summary>Whether any of the handlers have a custom priority.</summary> + private bool HasPriorities; /********* @@ -37,7 +40,7 @@ namespace StardewModdingAPI.Framework.Events public string EventName { get; } /// <inheritdoc /> - public bool IsPerformanceCritical { get; } + public bool HasListeners { get; private set; } /********* @@ -46,18 +49,10 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Construct an instance.</summary> /// <param name="eventName">A human-readable name for the event.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> - /// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param> - public ManagedEvent(string eventName, ModRegistry modRegistry, bool isPerformanceCritical = false) + public ManagedEvent(string eventName, ModRegistry modRegistry) { this.EventName = eventName; this.ModRegistry = modRegistry; - this.IsPerformanceCritical = isPerformanceCritical; - } - - /// <summary>Get whether anything is listening to the event.</summary> - public bool HasListeners() - { - return this.Handlers.Count > 0; } /// <summary>Add an event handler.</summary> @@ -72,7 +67,8 @@ namespace StardewModdingAPI.Framework.Events this.Handlers.Add(managedHandler); this.CachedHandlers = null; - this.HasNewHandlers = true; + this.HasListeners = true; + this.HasPriorities |= priority != EventPriority.Normal; } } @@ -90,6 +86,8 @@ namespace StardewModdingAPI.Framework.Events this.Handlers.RemoveAt(i); this.CachedHandlers = null; + this.HasListeners = this.Handlers.Count != 0; + this.HasRemovedHandlers = true; break; } } @@ -97,46 +95,40 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raise the event and notify all handlers.</summary> /// <param name="args">The event arguments to pass.</param> - /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> - public void Raise(TEventArgs args, Func<IModMetadata, bool>? match = null) - { - this.Raise((_, invoke) => invoke(args), match); - } - - /// <summary>Raise the event and notify all handlers.</summary> - /// <param name="invoke">Invoke an event handler. This receives the mod which registered the handler, and should invoke the callback with the event arguments to pass it.</param> - /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> - public void Raise(Action<IModMetadata, Action<TEventArgs>> invoke, Func<IModMetadata, bool>? match = null) + public void Raise(TEventArgs args) { // skip if no handlers 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) + // raise event + foreach (ManagedEventHandler<TEventArgs> handler in this.GetHandlers()) { - lock (this.Handlers) + try { - if (this.HasNewHandlers && this.Handlers.Any(p => p.Priority != EventPriority.Normal)) - this.Handlers.Sort(); - - this.CachedHandlers = handlers = this.Handlers.ToArray(); - this.HasNewHandlers = false; + handler.Handler(null, args); + } + catch (Exception ex) + { + this.LogError(handler, ex); } } + } + + /// <summary>Raise the event and notify all handlers.</summary> + /// <param name="invoke">Invoke an event handler. This receives the mod which registered the handler, and should invoke the callback with the event arguments to pass it.</param> + public void Raise(Action<IModMetadata, Action<TEventArgs>> invoke) + { + // skip if no handlers + if (this.Handlers.Count == 0) + return; // raise event - foreach (ManagedEventHandler<TEventArgs> handler in handlers) + foreach (ManagedEventHandler<TEventArgs> 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) { @@ -152,9 +144,36 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Log an exception from an event handler.</summary> /// <param name="handler">The event handler instance.</param> /// <param name="ex">The exception that was raised.</param> - protected void LogError(ManagedEventHandler<TEventArgs> handler, Exception ex) + private void LogError(ManagedEventHandler<TEventArgs> handler, Exception ex) { handler.SourceMod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); } + + /// <summary>Get cached copy of the sorted handlers to invoke.</summary> + /// <remarks>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.</remarks> + private ManagedEventHandler<TEventArgs>[] GetHandlers() + { + ManagedEventHandler<TEventArgs>[]? 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/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 +{ + /// <summary>Indicates why loading an asset through the content pipeline failed.</summary> + internal enum ContentLoadErrorType + { + /// <summary>The asset name is empty or has an invalid format.</summary> + InvalidName, + + /// <summary>The asset doesn't exist.</summary> + AssetDoesNotExist, + + /// <summary>The asset is not available in the current context (e.g. an attempt to load another mod's assets).</summary> + AccessDenied, + + /// <summary>The asset exists, but the data could not be deserialized or it doesn't match the expected type.</summary> + InvalidData, + + /// <summary>An unknown error occurred.</summary> + 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 @@ -7,12 +7,23 @@ namespace StardewModdingAPI.Framework.Exceptions internal class SContentLoadException : ContentLoadException { /********* + ** Accessors + *********/ + /// <summary>Why loading the asset through the content pipeline failed.</summary> + public ContentLoadErrorType ErrorType { get; } + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="errorType">Why loading the asset through the content pipeline failed.</param> /// <param name="message">The error message.</param> /// <param name="ex">The underlying exception, if any.</param> - 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/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 /// <param name="event">The event to raise.</param> public static void RaiseEmpty<TEventArgs>(this ManagedEvent<TEventArgs> @event) where TEventArgs : new() { - @event.Raise(Singleton<TEventArgs>.Instance); + if (@event.HasListeners) + @event.Raise(Singleton<TEventArgs>.Instance); } /**** diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 6a92da24..427adac2 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -132,15 +132,40 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.GameContentManager.LoadLocalized<T>(assetName, this.CurrentLocaleConstant, useCache: false); case ContentSource.ModFolder: - return this.ModContentManager.LoadExact<T>(assetName, useCache: false); + try + { + return this.ModContentManager.LoadExact<T>(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<T>(newName)) + { + T data = this.ModContentManager.LoadExact<T>(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($"{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); } } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index f882682e..5ae4fdbb 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; @@ -577,6 +578,7 @@ namespace StardewModdingAPI.Framework private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action runUpdate) { EventManager events = this.EventManager; + bool verbose = this.Monitor.IsVerbose; try { @@ -804,10 +806,11 @@ 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)); + if (events.WindowResized.HasListeners) + events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); } /********* @@ -822,40 +825,50 @@ 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 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)); + + 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) - { - SButton button = pair.Key; - SButtonState status = pair.Value; + bool raisePressed = events.ButtonPressed.HasListeners; + bool raiseReleased = events.ButtonReleased.HasListeners; - if (status == SButtonState.Pressed) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} pressed."); - - events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); - } - else if (status == SButtonState.Released) + if (verbose || raisePressed || raiseReleased) + { + foreach ((SButton button, SButtonState status) in inputState.ButtonStates) { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} released."); - - events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + switch (status) + { + case SButtonState.Pressed: + if (verbose) + this.Monitor.Log($"Events: button {button} pressed."); + + if (raisePressed) + events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); + break; + + case SButtonState.Released: + if (verbose) + this.Monitor.Log($"Events: button {button} released."); + + if (raiseReleased) + events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + break; + } } } } @@ -867,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 (this.Monitor.IsVerbose) + 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)); } /********* @@ -885,19 +899,20 @@ 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"; 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 @@ -905,51 +920,54 @@ 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()) + 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) - events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); + { + 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) @@ -960,32 +978,41 @@ 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!)); + 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 (this.Monitor.IsVerbose) - 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 (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)); + + if (events.InventoryChanged.HasListeners) + { + SnapshotItemListDiff inventory = playerState.Inventory; + events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged)); + } } } } @@ -997,7 +1024,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 @@ -1076,7 +1105,7 @@ namespace StardewModdingAPI.Framework } // raise event - if (this.EventManager.LocaleChanged.HasListeners()) + if (this.EventManager.LocaleChanged.HasListeners) { this.EventManager.LocaleChanged.Raise( new LocaleChangedEventArgs( @@ -1123,9 +1152,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(); } /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> @@ -1139,7 +1170,7 @@ namespace StardewModdingAPI.Framework /// <param name="assetName">The asset name that was loaded.</param> 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())); } @@ -1147,33 +1178,33 @@ namespace StardewModdingAPI.Framework /// <param name="assetNames">The invalidated asset names.</param> private void OnAssetsInvalidated(IList<IAssetName> assetNames) { - if (this.EventManager.AssetsInvalidated.HasListeners()) + if (this.EventManager.AssetsInvalidated.HasListeners) this.EventManager.AssetsInvalidated.Raise(new AssetsInvalidatedEventArgs(assetNames, assetNames.Select(p => p.GetBaseAssetName()))); } /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary> /// <param name="asset">The asset info being requested.</param> - private IList<AssetOperationGroup> RequestAssetOperations(IAssetInfo asset) + private AssetOperationGroup? RequestAssetOperations(IAssetInfo asset) { - List<AssetOperationGroup> operations = new(); + // get event + var requestedEvent = this.EventManager.AssetRequested; + if (!requestedEvent.HasListeners) + return null; - this.EventManager.AssetRequested.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.Add( - new AssetOperationGroup(mod, args.LoadOperations.ToArray(), args.EditOperations.ToArray()) - ); - } } ); - return operations; + // collect operations + return args.LoadOperations.Count != 0 || args.EditOperations.Count != 0 + ? new AssetOperationGroup(args.LoadOperations, args.EditOperations) + : null; } /// <summary>Get the mod metadata for a content pack whose ID matches <paramref name="id"/>, if it's a valid content pack for the given <paramref name="mod"/>.</summary> @@ -1225,13 +1256,26 @@ namespace StardewModdingAPI.Framework /// <param name="message">The message to deliver to applicable mods.</param> private void OnModMessageReceived(ModMessageModel message) { - // get mod IDs to notify - HashSet<string> modIDs = new HashSet<string>(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 - this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => modIDs.Contains(mod.Manifest.UniqueID)); + if (this.EventManager.ModMessageReceived.HasListeners) + { + // get mod IDs to notify + HashSet<string> 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)) + { + args ??= new(message, this.Toolkit.JsonHelper); + invoke(args); + } + } + ); + } } /// <summary>Constructor a content manager to read game content files.</summary> 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(); 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)); } 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<Dictionary<string, Dictionary<int, List<int>>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue()?.Remove(player.getTexture()); + var recolorOffsets = this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue(); + recolorOffsets?.Clear(); + player.FarmerRenderer.MarkSpriteDirty(); } |