diff --git a/src/SMAPI/Framework/Content/AssetEditOperation.cs b/src/SMAPI/Framework/Content/AssetEditOperation.cs new file mode 100644 index 00000000..fa189d44 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetEditOperation.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Framework.Content +{ + /// An edit to apply to an asset when it's requested from the content pipeline. + internal class AssetEditOperation + { + /********* + ** Accessors + *********/ + /// The mod applying the edit. + public IModMetadata Mod { get; } + + /// Apply the edit to an asset. + public Action ApplyEdit { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod applying the edit. + /// Apply the edit to an asset. + public AssetEditOperation(IModMetadata mod, Action applyEdit) + { + this.Mod = mod; + this.ApplyEdit = applyEdit; + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetLoadOperation.cs b/src/SMAPI/Framework/Content/AssetLoadOperation.cs new file mode 100644 index 00000000..d773cadd --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetLoadOperation.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Framework.Content +{ + /// An operation which provides the initial instance of an asset when it's requested from the content pipeline. + internal class AssetLoadOperation + { + /********* + ** Accessors + *********/ + /// The mod applying the edit. + public IModMetadata Mod { get; } + + /// Load the initial value for an asset. + public Func GetData { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod applying the edit. + /// Load the initial value for an asset. + public AssetLoadOperation(IModMetadata mod, Func getData) + { + this.Mod = mod; + this.GetData = getData; + } + } +} diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 9b8125ad..7ed1fcda 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Framework.ContentManagers ** Fields *********/ /// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset. - private readonly ContextHash AssetsBeingLoaded = new ContextHash(); + private readonly ContextHash AssetsBeingLoaded = new(); /// Interceptors which provide the initial versions of matching assets. private IList> Loaders => this.Coordinator.Loaders; @@ -79,12 +79,10 @@ namespace StardewModdingAPI.Framework.ContentManagers // custom asset from a loader string locale = this.GetLocale(); IAssetInfo info = new AssetInfo(locale, assetName, typeof(object), this.AssertAndNormalizeAssetName); - ModLinked[] loaders = this.GetLoaders(info).ToArray(); - if (loaders.Length > 1) - { - string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); - this.Monitor.Log($"Multiple mods want to provide the '{info.Name}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. 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.)", LogLevel.Warn); - } + AssetLoadOperation[] loaders = this.GetLoaders(info).ToArray(); + + if (!this.AssertMaxOneLoader(info, loaders, out string error)) + this.Monitor.Log(error, LogLevel.Warn); return loaders.Length == 1; } @@ -261,7 +259,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // try base asset return base.RawLoad(assetName.Name, useCache); } - catch (ContentLoadException ex) when (ex.InnerException is FileNotFoundException innerEx && innerEx.InnerException == null) + catch (ContentLoadException ex) when (ex.InnerException is FileNotFoundException { InnerException: null }) { throw new SContentLoadException($"Error loading \"{assetName}\": it isn't in the Content folder and no mod provided it."); } @@ -272,27 +270,31 @@ namespace StardewModdingAPI.Framework.ContentManagers /// Returns the loaded asset metadata, or null if no loader matched. private IAssetData ApplyLoader(IAssetInfo info) { - // find matching loaders - var loaders = this.GetLoaders(info).ToArray(); - - // validate loaders - if (!loaders.Any()) - return null; - if (loaders.Length > 1) + // find matching loader + AssetLoadOperation loader; { - string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); - this.Monitor.Log($"Multiple mods want to provide the '{info.Name}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. 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.)", LogLevel.Warn); - return null; + AssetLoadOperation[] loaders = this.GetLoaders(info).ToArray(); + + if (!this.AssertMaxOneLoader(info, loaders, out string error)) + { + this.Monitor.Log(error, LogLevel.Warn); + return null; + } + + loader = loaders.FirstOrDefault(); } + // no loader found + if (loader == null) + return null; + // fetch asset from loader - IModMetadata mod = loaders[0].Mod; - IAssetLoader loader = loaders[0].Data; + IModMetadata mod = loader.Mod; T data; try { - data = loader.Load(info); - this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'.", LogLevel.Trace); + data = (T)loader.GetData(info); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'."); } catch (Exception ex) { @@ -322,34 +324,23 @@ namespace StardewModdingAPI.Framework.ContentManagers if (typeof(T) != actualType && (actualOpenType == typeof(Dictionary<,>) || actualOpenType == typeof(List<>) || actualType == typeof(Texture2D) || actualType == typeof(Map))) { return (IAssetData)this.GetType() - .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance) + .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(actualType) .Invoke(this, new object[] { info, asset }); } } // edit asset - foreach (var entry in this.Editors) + AssetEditOperation[] editors = this.GetEditors(info).ToArray(); + foreach (AssetEditOperation editor in editors) { - // check for match - IModMetadata mod = entry.Mod; - IAssetEditor editor = entry.Data; - try - { - if (!editor.CanEdit(info)) - continue; - } - catch (Exception ex) - { - mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } + IModMetadata mod = editor.Mod; // try edit object prevAsset = asset.Data; try { - editor.Edit(asset); + editor.ApplyEdit(asset); this.Monitor.Log($"{mod.DisplayName} edited {info.Name}."); } catch (Exception ex) @@ -374,24 +365,72 @@ namespace StardewModdingAPI.Framework.ContentManagers return asset; } - /// Get the asset loaders which handle the asset. + /// Get the asset loaders which handle an asset. /// The asset type. /// The basic asset metadata. - private IEnumerable> GetLoaders(IAssetInfo info) + private IEnumerable GetLoaders(IAssetInfo info) { return this.Loaders - .Where(entry => + .Where(loader => { try { - return entry.Data.CanLoad(info); + return loader.Data.CanLoad(info); } catch (Exception ex) { - entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return false; } - }); + }) + .Select( + loader => new AssetLoadOperation(loader.Mod, assetInfo => loader.Data.Load(assetInfo)) + ); + } + + /// Get the asset editors to apply to an asset. + /// The asset type. + /// The basic asset metadata. + private IEnumerable GetEditors(IAssetInfo info) + { + return this.Editors + .Where(editor => + { + try + { + return editor.Data.CanEdit(info); + } + catch (Exception ex) + { + editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return false; + } + }) + .Select( + editor => new AssetEditOperation(editor.Mod, assetData => editor.Data.Edit(assetData)) + ); + } + + /// 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 AssertMaxOneLoader(IAssetInfo info, AssetLoadOperation[] loaders, out string error) + { + if (loaders.Length <= 1) + { + error = null; + return true; + } + + string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); + string errorPhrase = loaderNames.Length > 1 + ? $"Multiple mods want to provide '{info.Name}' asset ({string.Join(", ", loaderNames)})" + : $"The '{loaderNames[0]}' mod wants to provide the '{info.Name}' asset multiple times"; + + error = $"{errorPhrase}, but an asset can't be loaded multiple times. 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.)"; + return false; } /// Validate that an asset loaded by a mod is valid and won't cause issues, and fix issues if possible.