diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-05-03 18:11:31 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-05-03 18:11:31 -0400 |
commit | 5d3d919d490fd414fe9647e566e92c71d7f64509 (patch) | |
tree | e1eab3352287ef04b5de4cdc28b550e255d58c3f /src/SMAPI | |
parent | c48f6d78cc412c5f2e40a8b460b7b3c1c993c51a (diff) | |
parent | 3447e2f575c2c83af729777e4d37e93f4c2a6467 (diff) | |
download | SMAPI-5d3d919d490fd414fe9647e566e92c71d7f64509.tar.gz SMAPI-5d3d919d490fd414fe9647e566e92c71d7f64509.tar.bz2 SMAPI-5d3d919d490fd414fe9647e566e92c71d7f64509.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI')
24 files changed, 372 insertions, 159 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 8b0c952d..3c21b205 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -38,6 +38,14 @@ namespace StardewModdingAPI /// <summary>The target game platform.</summary> internal static GamePlatform Platform { get; } = (GamePlatform)Enum.Parse(typeof(GamePlatform), LowLevelEnvironmentUtility.DetectPlatform()); + /// <summary>Whether SMAPI is being compiled for Windows with a 64-bit Linux version of the game. This is highly specialized and shouldn't be used in most cases.</summary> + internal static bool IsWindows64BitHack { get; } = +#if SMAPI_FOR_WINDOWS_64BIT_HACK + true; +#else + false; +#endif + /// <summary>The game framework running the game.</summary> internal static GameFramework GameFramework { get; } = #if SMAPI_FOR_XNA @@ -47,10 +55,13 @@ namespace StardewModdingAPI #endif /// <summary>The game's assembly name.</summary> - internal static string GameAssemblyName => EarlyConstants.Platform == GamePlatform.Windows ? "Stardew Valley" : "StardewValley"; + internal static string GameAssemblyName => EarlyConstants.Platform == GamePlatform.Windows && !EarlyConstants.IsWindows64BitHack ? "Stardew Valley" : "StardewValley"; /// <summary>The <see cref="Context.ScreenId"/> value which should appear in the SMAPI log, if any.</summary> internal static int? LogScreenId { get; set; } + + /// <summary>SMAPI's current raw semantic version.</summary> + internal static string RawApiVersion = "3.10.0"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -63,7 +74,7 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.9.5"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion(EarlyConstants.RawApiVersion); /// <summary>The minimum supported version of Stardew Valley.</summary> public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.4"); @@ -231,33 +242,27 @@ namespace StardewModdingAPI targetAssemblies.Add(typeof(StardewModdingAPI.IManifest).Assembly); // get changes for platform - switch (targetPlatform) + if (Constants.Platform != Platform.Windows || EarlyConstants.IsWindows64BitHack) { - case Platform.Linux: - case Platform.Mac: - removeAssemblyReferences.AddRange(new[] - { - "Netcode", - "Stardew Valley" - }); - targetAssemblies.Add( - typeof(StardewValley.Game1).Assembly // note: includes Netcode types on Linux/Mac - ); - break; - - case Platform.Windows: - removeAssemblyReferences.Add( - "StardewValley" - ); - targetAssemblies.AddRange(new[] - { - typeof(Netcode.NetBool).Assembly, - typeof(StardewValley.Game1).Assembly - }); - break; - - default: - throw new InvalidOperationException($"Unknown target platform '{targetPlatform}'."); + removeAssemblyReferences.AddRange(new[] + { + "Netcode", + "Stardew Valley" + }); + targetAssemblies.Add( + typeof(StardewValley.Game1).Assembly // note: includes Netcode types on Linux/macOS + ); + } + else + { + removeAssemblyReferences.Add( + "StardewValley" + ); + targetAssemblies.AddRange(new[] + { + typeof(Netcode.NetBool).Assembly, + typeof(StardewValley.Game1).Assembly + }); } // get changes for game framework @@ -295,6 +300,21 @@ namespace StardewModdingAPI return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences.ToArray(), targetAssemblies.ToArray()); } + /// <summary>Get whether the game assembly was patched by Stardew64Installer.</summary> + /// <param name="version">The version of Stardew64Installer which was applied to the game assembly, if any.</param> + internal static bool IsPatchedByStardew64Installer(out ISemanticVersion version) + { + PropertyInfo property = typeof(Game1).GetProperty("Stardew64InstallerVersion"); + if (property == null) + { + version = null; + return false; + } + + version = new SemanticVersion((string)property.GetValue(null)); + return true; + } + /********* ** Private methods diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index 5f70d0f7..a745592c 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -86,7 +86,7 @@ namespace StardewModdingAPI public static bool HasRemotePlayers => Context.IsMultiplayer && !Game1.hasLocalClientsOnly; /// <summary>Whether the current player is the main player. This is always true in single-player, and true when hosting in multiplayer.</summary> - public static bool IsMainPlayer => Game1.IsMasterGame && !(TitleMenu.subMenu is FarmhandMenu); + public static bool IsMainPlayer => Game1.IsMasterGame && Context.ScreenId == 0 && !(TitleMenu.subMenu is FarmhandMenu); /********* diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 5f91610e..529fb93a 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -12,7 +12,7 @@ namespace StardewModdingAPI.Framework.Content ** Fields *********/ /// <summary>The minimum value to consider non-transparent.</summary> - /// <remarks>On Linux/Mac, fully transparent pixels may have an alpha up to 4 for some reason.</remarks> + /// <remarks>On Linux/macOS, fully transparent pixels may have an alpha up to 4 for some reason.</remarks> private const byte MinOpacity = 5; @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework.Content // premultiplied by the content pipeline. The formula is derived from // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. // Note: don't use named arguments here since they're different between - // Linux/Mac and Windows. + // Linux/macOS and Windows. float alphaBelow = 1 - (above.A / 255f); newData[i] = new Color( (int)(above.R + (below.R * alphaBelow)), // r diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 7edc9ab9..5c7ad778 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -57,6 +57,8 @@ namespace StardewModdingAPI.Framework.Content IReflectedMethod method = reflection.GetMethod(typeof(TitleContainer), "GetCleanPath"); this.NormalizeAssetNameForPlatform = path => method.Invoke<string>(path); } + else if (EarlyConstants.IsWindows64BitHack) + this.NormalizeAssetNameForPlatform = PathUtilities.NormalizePath; else this.NormalizeAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic } diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 2920e670..d0e759c2 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -132,7 +132,7 @@ namespace StardewModdingAPI.Framework ); this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, reflection, aggressiveMemoryOptimizations); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 9af14cb5..4f6aa775 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -34,6 +34,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The language code for language-agnostic mod assets.</summary> private readonly LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary> + private readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; + /********* ** Public methods @@ -215,11 +218,17 @@ namespace StardewModdingAPI.Framework.ContentManagers FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path)); // try with default extension - if (!file.Exists && file.Extension.ToLower() != ".xnb") + if (!file.Exists && file.Extension == string.Empty) { - FileInfo result = new FileInfo(file.FullName + ".xnb"); - if (result.Exists) - file = result; + foreach (string extension in this.LocalTilesheetExtensions) + { + FileInfo result = new FileInfo(file.FullName + extension); + if (result.Exists) + { + file = result; + break; + } + } } return file; @@ -259,6 +268,7 @@ namespace StardewModdingAPI.Framework.ContentManagers string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder // fix tilesheets + this.Monitor.VerboseLog($"Fixing tilesheet paths for map '{relativeMapPath}' from mod '{this.ModName}'..."); foreach (TileSheet tilesheet in map.TileSheets) { // get image source @@ -280,6 +290,9 @@ namespace StardewModdingAPI.Framework.ContentManagers if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out string assetName, out string error)) throw new SContentLoadException($"{errorPrefix} {error}"); + if (assetName != tilesheet.ImageSource) + this.Monitor.VerboseLog($" Mapped tilesheet '{tilesheet.ImageSource}' to '{assetName}'."); + tilesheet.ImageSource = assetName; } catch (Exception ex) when (!(ex is SContentLoadException)) @@ -308,6 +321,15 @@ namespace StardewModdingAPI.Framework.ContentManagers return true; } + // special case: local filenames starting with a dot should be ignored + // For example, this lets mod authors have a '.spring_town.png' file in their map folder so it can be + // opened in Tiled, while still mapping it to the vanilla 'Maps/spring_town' asset at runtime. + { + string filename = Path.GetFileName(relativePath); + if (filename.StartsWith(".")) + relativePath = Path.Combine(Path.GetDirectoryName(relativePath) ?? "", filename.TrimStart('.')); + } + // get relative to map file { string localKey = Path.Combine(modRelativeMapFolder, relativePath); diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index a6835dbe..0660a367 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; - /// <summary>A cache of case-insensitive => exact relative paths within the content pack, for case-insensitive file lookups on Linux/Mac.</summary> + /// <summary>A cache of case-insensitive => exact relative paths within the content pack, for case-insensitive file lookups on Linux/macOS.</summary> private readonly IDictionary<string, string> RelativePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 5d2f352d..f5babafb 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -117,6 +117,11 @@ namespace StardewModdingAPI.Framework /// <param name="validOnly">Only return valid update keys.</param> IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true); + /// <summary>Get whether the given mod ID must be installed to load this mod.</summary> + /// <param name="modId">The mod ID to check.</param> + /// <param name="includeOptional">Whether to include optional dependencies.</param> + bool HasRequiredModId(string modId, bool includeOptional); + /// <summary>Get the mod IDs that must be installed to load this mod.</summary> /// <param name="includeOptional">Whether to include optional dependencies.</param> IEnumerable<string> GetRequiredModIds(bool includeOptional = false); diff --git a/src/SMAPI/Framework/Logging/LogFileManager.cs b/src/SMAPI/Framework/Logging/LogFileManager.cs index 6b5babcd..6ab2bdfb 100644 --- a/src/SMAPI/Framework/Logging/LogFileManager.cs +++ b/src/SMAPI/Framework/Logging/LogFileManager.cs @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework.Logging public void WriteLine(string message) { // always use Windows-style line endings for convenience - // (Linux/Mac editors are fine with them, Windows editors often require them) + // (Linux/macOS editors are fine with them, Windows editors often require them) this.Stream.Write(message + "\r\n"); } diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index 243ca3ae..a4df3c18 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -283,13 +283,16 @@ namespace StardewModdingAPI.Framework.Logging /// <param name="customSettings">The custom SMAPI settings.</param> public void LogIntro(string modsPath, IDictionary<string, object> customSettings) { - // get platform label - string platformLabel = EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform); - if ((Constants.GameFramework == GameFramework.Xna) != (Constants.Platform == Platform.Windows)) - platformLabel += $" with {Constants.GameFramework}"; + // log platform & patches + { + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + + string[] patchLabels = this.GetPatchLabels().ToArray(); + if (patchLabels.Any()) + this.Monitor.Log($"Detected custom version: {string.Join(", ", patchLabels)}", LogLevel.Info); + } - // init logging - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {platformLabel}", LogLevel.Info); + // log basic info this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); if (modsPath != Constants.DefaultModsPath) this.Monitor.Log("(Using custom --mods-path argument.)"); @@ -406,6 +409,20 @@ namespace StardewModdingAPI.Framework.Logging gameMonitor.Log(message, level); } + /// <summary>Get human-readable labels to log for detected SMAPI and Stardew Valley customizations.</summary> + private IEnumerable<string> GetPatchLabels() + { + // custom game framework + if (EarlyConstants.IsWindows64BitHack) + yield return $"running 64-bit SMAPI with {Constants.GameFramework}"; + else if ((Constants.GameFramework == GameFramework.Xna) != (Constants.Platform == Platform.Windows)) + yield return $"running {Constants.GameFramework}"; + + // patched by Stardew64Installer + if (Constants.IsPatchedByStardew64Installer(out ISemanticVersion patchedByVersion)) + yield return $"patched by Stardew64Installer {patchedByVersion}"; + } + /// <summary>Write a summary of mod warnings to the console and log.</summary> /// <param name="mods">The loaded mods.</param> /// <param name="skippedMods">The mods which could not be loaded.</param> @@ -426,67 +443,38 @@ namespace StardewModdingAPI.Framework.Logging // log skipped mods if (skippedMods.Any()) { - // get logging logic - HashSet<string> loggedDuplicateIds = new HashSet<string>(); - void LogSkippedMod(IModMetadata mod) - { - string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {mod.Error}"; + var loggedDuplicateIds = new HashSet<string>(); - // handle duplicate mods - // (log first duplicate only, don't show redundant version) - if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest()) + this.Monitor.Log(" Skipped mods", LogLevel.Error); + this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); + this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); + this.Monitor.Newline(); + foreach (var list in this.GroupFailedModsByPriority(skippedMods)) + { + if (list.Any()) { - if (!loggedDuplicateIds.Add(mod.Manifest.UniqueID)) - return; // already logged + foreach (IModMetadata mod in list.OrderBy(p => p.DisplayName)) + { + string message = $" - {mod.DisplayName}{(" " + mod.Manifest?.Version?.ToString()).TrimEnd()} because {mod.Error}"; - message = $" - {mod.DisplayName} because {mod.Error}"; - } + // duplicate mod: log first one only, don't show redundant version + if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest()) + { + if (loggedDuplicateIds.Add(mod.Manifest.UniqueID)) + continue; // already logged - // log message - this.Monitor.Log(message, LogLevel.Error); - if (mod.ErrorDetails != null) - this.Monitor.Log($" ({mod.ErrorDetails})"); - } + message = $" - {mod.DisplayName} because {mod.Error}"; + } - // group mods - List<IModMetadata> skippedDependencies = new List<IModMetadata>(); - List<IModMetadata> otherSkippedMods = new List<IModMetadata>(); - { - // track broken dependencies - HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.HasID() select mod.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); - foreach (IModMetadata mod in skippedMods) - { - foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) - skippedDependencyIds.Add(requiredId); - } + // log message + this.Monitor.Log(message, LogLevel.Error); + if (mod.ErrorDetails != null) + this.Monitor.Log($" ({mod.ErrorDetails})"); + } - // collect mod groups - foreach (IModMetadata mod in skippedMods) - { - if (mod.HasID() && skippedDependencyIds.Contains(mod.Manifest.UniqueID)) - skippedDependencies.Add(mod); - else - otherSkippedMods.Add(mod); + this.Monitor.Newline(); } } - - // log skipped mods - this.Monitor.Log(" Skipped mods", LogLevel.Error); - this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); - this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); - this.Monitor.Newline(); - - if (skippedDependencies.Any()) - { - foreach (IModMetadata mod in skippedDependencies.OrderBy(p => p.DisplayName)) - LogSkippedMod(mod); - this.Monitor.Newline(); - } - - foreach (IModMetadata mod in otherSkippedMods.OrderBy(p => p.DisplayName)) - LogSkippedMod(mod); - this.Monitor.Newline(); } // log warnings @@ -553,9 +541,95 @@ namespace StardewModdingAPI.Framework.Logging // not crossplatform this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", - "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." + "These mods use the 'dynamic' keyword, and won't work on Linux/macOS." + ); + } + } + + /// <summary>Group failed mods by the priority players should update them, where mods in earlier groups are more likely to fix multiple mods.</summary> + /// <param name="failedMods">The failed mods to group.</param> + private IEnumerable<IList<IModMetadata>> GroupFailedModsByPriority(IList<IModMetadata> failedMods) + { + var failedOthers = failedMods.ToList(); + var skippedModIds = new HashSet<string>(from mod in failedMods where mod.HasID() select mod.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); + + // group B: dependencies which failed + var failedOtherDependencies = new List<IModMetadata>(); + { + // get failed dependency IDs + var skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (IModMetadata mod in failedMods) + { + foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) + skippedDependencyIds.Add(requiredId); + } + + // group matching mods + this.FilterThrough( + fromList: failedOthers, + toList: failedOtherDependencies, + match: mod => mod.HasID() && skippedDependencyIds.Contains(mod.Manifest.UniqueID) ); } + + // group A: failed root dependencies which other dependencies need + var failedRootDependencies = new List<IModMetadata>(); + { + var skippedDependencyIds = new HashSet<string>(failedOtherDependencies.Select(p => p.Manifest.UniqueID)); + this.FilterThrough( + fromList: failedOtherDependencies, + toList: failedRootDependencies, + match: mod => + { + // has no failed dependency + foreach (string requiredId in mod.GetRequiredModIds()) + { + if (skippedDependencyIds.Contains(requiredId)) + return false; + } + + // another dependency depends on this mod + bool isDependedOn = false; + foreach (IModMetadata other in failedOtherDependencies) + { + if (other.HasRequiredModId(mod.Manifest.UniqueID, includeOptional: false)) + { + isDependedOn = true; + break; + } + } + + return isDependedOn; + } + ); + } + + // return groups + return new[] + { + failedRootDependencies, + failedOtherDependencies, + failedOthers + }; + } + + /// <summary>Filter matching items from one list and add them to the other.</summary> + /// <typeparam name="TItem">The list item type.</typeparam> + /// <param name="fromList">The list to filter.</param> + /// <param name="toList">The list to which to add filtered items.</param> + /// <param name="match">Matches items to filter through.</param> + private void FilterThrough<TItem>(IList<TItem> fromList, IList<TItem> toList, Func<TItem, bool> match) + { + for (int i = 0; i < fromList.Count; i++) + { + TItem item = fromList[i]; + if (match(item)) + { + toList.Add(item); + fromList.RemoveAt(i); + i--; + } + } } /// <summary>Write a mod warning group to the console and log.</summary> diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index a948213b..baffc50e 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The instruction is compatible, but affects the save serializer in a way that may make saves unloadable without the mod.</summary> DetectedSaveSerializer, - /// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary> + /// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/macOS.</summary> DetectedDynamic, /// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary> diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index b4de3d6c..17e6d59a 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -19,6 +19,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The non-error issues with the mod, including warnings suppressed by the data record.</summary> private ModWarning ActualWarnings = ModWarning.None; + /// <summary>The mod IDs which are listed as a requirement by this mod. The value for each pair indicates whether the dependency is required (i.e. not an optional dependency).</summary> + private readonly Lazy<IDictionary<string, bool>> Dependencies; + /********* ** Accessors @@ -100,6 +103,8 @@ namespace StardewModdingAPI.Framework.ModLoading this.Manifest = manifest; this.DataRecord = dataRecord; this.IsIgnored = isIgnored; + + this.Dependencies = new Lazy<IDictionary<string, bool>>(this.ExtractDependencies); } /// <inheritdoc /> @@ -199,23 +204,21 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <inheritdoc /> - public IEnumerable<string> GetRequiredModIds(bool includeOptional = false) + public bool HasRequiredModId(string modId, bool includeOptional) { - HashSet<string> required = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + return + this.Dependencies.Value.TryGetValue(modId, out bool isRequired) + && (includeOptional || isRequired); + } - // yield dependencies - if (this.Manifest?.Dependencies != null) + /// <inheritdoc /> + public IEnumerable<string> GetRequiredModIds(bool includeOptional = false) + { + foreach (var pair in this.Dependencies.Value) { - foreach (var entry in this.Manifest?.Dependencies) - { - if ((entry.IsRequired || includeOptional) && required.Add(entry.UniqueID)) - yield return entry.UniqueID; - } + if (includeOptional || pair.Value) + yield return pair.Key; } - - // yield content pack parent - if (this.Manifest?.ContentPackFor?.UniqueID != null && required.Add(this.Manifest.ContentPackFor.UniqueID)) - yield return this.Manifest.ContentPackFor.UniqueID; } /// <inheritdoc /> @@ -237,5 +240,29 @@ namespace StardewModdingAPI.Framework.ModLoading string rootFolderName = Path.GetFileName(this.RootPath) ?? ""; return Path.Combine(rootFolderName, this.RelativeDirectoryPath); } + + + /********* + ** Private methods + *********/ + /// <summary>Extract mod IDs from the manifest that must be installed to load this mod.</summary> + /// <returns>Returns a dictionary of mod ID => is required (i.e. not an optional dependency).</returns> + public IDictionary<string, bool> ExtractDependencies() + { + var ids = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); + + // yield dependencies + if (this.Manifest?.Dependencies != null) + { + foreach (var entry in this.Manifest?.Dependencies) + ids[entry.UniqueID] = entry.IsRequired; + } + + // yield content pack parent + if (this.Manifest?.ContentPackFor?.UniqueID != null) + ids[this.Manifest.ContentPackFor.UniqueID] = true; + + return ids; + } } } diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs index cf71af77..aefd1c20 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs @@ -4,10 +4,10 @@ using Microsoft.Xna.Framework.Graphics; namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades { - /// <summary>Provides <see cref="SpriteBatch"/> method signatures that can be injected into mod code for compatibility between Linux/Mac or Windows.</summary> + /// <summary>Provides <see cref="SpriteBatch"/> method signatures that can be injected into mod code for compatibility between Linux/macOS or Windows.</summary> /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks> [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via assembly rewriting")] - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Linux/Mac.")] + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Linux/macOS.")] [SuppressMessage("ReSharper", "CS1591", Justification = "Documentation not needed for facade classes.")] public class SpriteBatchFacade : SpriteBatch { diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 4a80e34c..a71bafd9 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -52,6 +52,9 @@ namespace StardewModdingAPI.Framework.Models /// <summary>SMAPI's GitHub project name, used to perform update checks.</summary> public string GitHubProjectName { get; set; } + /// <summary>Stardew64Installer's GitHub project name, used to perform update checks.</summary> + public string Stardew64InstallerGitHubProjectName { get; set; } + /// <summary>The base URL for SMAPI's web API, used to perform update checks.</summary> public string WebApiBaseUrl { get; set; } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index ebb21555..5862b112 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -187,7 +187,7 @@ namespace StardewModdingAPI.Framework #if SMAPI_FOR_WINDOWS if (Constants.Platform != Platform.Windows) { - this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or Mac. Please reinstall SMAPI to fix this.", LogLevel.Error); + this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or macOS. Please reinstall SMAPI to fix this.", LogLevel.Error); this.LogManager.PressAnyKeyToExit(); } #else @@ -263,10 +263,7 @@ namespace StardewModdingAPI.Framework }); // set window titles - this.SetWindowTitles( - game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}", - smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}" - ); + this.UpdateWindowTitles(); } catch (Exception ex) { @@ -280,10 +277,7 @@ namespace StardewModdingAPI.Framework this.LogManager.LogSettingsHeader(this.Settings); // set window titles - this.SetWindowTitles( - game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}", - smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}" - ); + this.UpdateWindowTitles(); // start game this.Monitor.Log("Starting game...", LogLevel.Debug); @@ -387,11 +381,7 @@ namespace StardewModdingAPI.Framework } // update window titles - int modsLoaded = this.ModRegistry.GetAll().Count(); - this.SetWindowTitles( - game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods", - smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods" - ); + this.UpdateWindowTitles(); } /// <summary>Raised after the game finishes initializing.</summary> @@ -419,7 +409,7 @@ namespace StardewModdingAPI.Framework Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice); // log GPU info -#if SMAPI_FOR_WINDOWS +#if SMAPI_FOR_WINDOWS && !SMAPI_FOR_WINDOWS_64BIT_HACK this.Monitor.Log($"Running on GPU: {Game1.game1.GraphicsDevice?.Adapter?.Description ?? "<unknown>"}"); #endif } @@ -1238,13 +1228,23 @@ namespace StardewModdingAPI.Framework return !issuesFound; } - /// <summary>Set the window titles for the game and console windows.</summary> - /// <param name="game">The game window text.</param> - /// <param name="smapi">The SMAPI window text.</param> - private void SetWindowTitles(string game, string smapi) + /// <summary>Set the titles for the game and console windows.</summary> + private void UpdateWindowTitles() { - this.Game.Window.Title = game; - this.LogManager.SetConsoleTitle(smapi); + string smapiVersion = $"{Constants.ApiVersion}{(EarlyConstants.IsWindows64BitHack ? " [64-bit]" : "")}"; + + string consoleTitle = $"SMAPI {smapiVersion} - running Stardew Valley {Constants.GameVersion}"; + string gameTitle = $"Stardew Valley {Constants.GameVersion} - running SMAPI {smapiVersion}"; + + if (this.ModRegistry.AreAllModsLoaded) + { + int modsLoaded = this.ModRegistry.GetAll().Count(); + consoleTitle += $" with {modsLoaded} mods"; + gameTitle += $" with {modsLoaded} mods"; + } + + this.Game.Window.Title = gameTitle; + this.LogManager.SetConsoleTitle(consoleTitle); } /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary> @@ -1259,7 +1259,7 @@ namespace StardewModdingAPI.Framework // create client string url = this.Settings.WebApiBaseUrl; #if !SMAPI_FOR_WINDOWS - url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac + url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/macOS #endif WebApiClient client = new WebApiClient(url, Constants.ApiVersion); this.Monitor.Log("Checking for updates..."); @@ -1302,6 +1302,41 @@ namespace StardewModdingAPI.Framework this.LogManager.WriteUpdateMarker(updateFound.ToString(), updateUrl); } + // check Stardew64Installer version + if (Constants.IsPatchedByStardew64Installer(out ISemanticVersion patchedByVersion)) + { + ISemanticVersion updateFound = null; + string updateUrl = null; + try + { + // fetch update check + ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Steviegt6.Stardew64Installer", patchedByVersion, new[] { $"GitHub:{this.Settings.Stardew64InstallerGitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value; + updateFound = response.SuggestedUpdate?.Version; + updateUrl = response.SuggestedUpdate?.Url ?? Constants.HomePageUrl; + + // log message + if (updateFound != null) + this.Monitor.Log($"You can update Stardew64Installer to {updateFound}: {updateUrl}", LogLevel.Alert); + else + this.Monitor.Log(" Stardew64Installer okay."); + + // show errors + if (response.Errors.Any()) + { + this.Monitor.Log("Couldn't check for a new version of Stardew64Installer. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); + } + } + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for a new version of Stardew64Installer. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? $"Error: {ex.Message}" + : $"Error: {ex.GetLogSummary()}" + ); + } + } + // check mod versions if (mods.Any()) { diff --git a/src/SMAPI/Framework/Serialization/ColorConverter.cs b/src/SMAPI/Framework/Serialization/ColorConverter.cs index 7315f1a5..3b3720b5 100644 --- a/src/SMAPI/Framework/Serialization/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialization/ColorConverter.cs @@ -8,7 +8,7 @@ namespace StardewModdingAPI.Framework.Serialization { /// <summary>Handles deserialization of <see cref="Color"/> for crossplatform compatibility.</summary> /// <remarks> - /// - Linux/Mac format: { "B": 76, "G": 51, "R": 25, "A": 102 } + /// - Linux/macOS format: { "B": 76, "G": 51, "R": 25, "A": 102 } /// - Windows format: "26, 51, 76, 102" /// </remarks> internal class ColorConverter : SimpleReadOnlyConverter<Color> diff --git a/src/SMAPI/Framework/Serialization/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index 6cf795dc..21d1f845 100644 --- a/src/SMAPI/Framework/Serialization/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -8,7 +8,7 @@ namespace StardewModdingAPI.Framework.Serialization { /// <summary>Handles deserialization of <see cref="Point"/> for crossplatform compatibility.</summary> /// <remarks> - /// - Linux/Mac format: { "X": 1, "Y": 2 } + /// - Linux/macOS format: { "X": 1, "Y": 2 } /// - Windows format: "1, 2" /// </remarks> internal class PointConverter : SimpleReadOnlyConverter<Point> diff --git a/src/SMAPI/Framework/Serialization/RectangleConverter.cs b/src/SMAPI/Framework/Serialization/RectangleConverter.cs index 8f7318b3..31f3ad77 100644 --- a/src/SMAPI/Framework/Serialization/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialization/RectangleConverter.cs @@ -9,7 +9,7 @@ namespace StardewModdingAPI.Framework.Serialization { /// <summary>Handles deserialization of <see cref="Rectangle"/> for crossplatform compatibility.</summary> /// <remarks> - /// - Linux/Mac format: { "X": 1, "Y": 2, "Width": 3, "Height": 4 } + /// - Linux/macOS format: { "X": 1, "Y": 2, "Width": 3, "Height": 4 } /// - Windows format: "{X:1 Y:2 Width:3 Height:4}" /// </remarks> internal class RectangleConverter : SimpleReadOnlyConverter<Rectangle> diff --git a/src/SMAPI/Framework/Serialization/Vector2Converter.cs b/src/SMAPI/Framework/Serialization/Vector2Converter.cs index 3e2ab776..589febf8 100644 --- a/src/SMAPI/Framework/Serialization/Vector2Converter.cs +++ b/src/SMAPI/Framework/Serialization/Vector2Converter.cs @@ -8,7 +8,7 @@ namespace StardewModdingAPI.Framework.Serialization { /// <summary>Handles deserialization of <see cref="Vector2"/> for crossplatform compatibility.</summary> /// <remarks> - /// - Linux/Mac format: { "X": 1, "Y": 2 } + /// - Linux/macOS format: { "X": 1, "Y": 2 } /// - Windows format: "1, 2" /// </remarks> internal class Vector2Converter : SimpleReadOnlyConverter<Vector2> diff --git a/src/SMAPI/GamePlatform.cs b/src/SMAPI/GamePlatform.cs index b64595e4..cce5ed8d 100644 --- a/src/SMAPI/GamePlatform.cs +++ b/src/SMAPI/GamePlatform.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI /// <summary>The Linux version of the game.</summary> Linux = Platform.Linux, - /// <summary>The Mac version of the game.</summary> + /// <summary>The macOS version of the game.</summary> Mac = Platform.Mac, /// <summary>The Windows version of the game.</summary> diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 52da3946..623c65d5 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using Microsoft.Xna.Framework.Graphics; using Netcode; +using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Utilities; @@ -36,15 +37,18 @@ namespace StardewModdingAPI.Metadata /// <summary>An internal content manager used only for asset propagation. See remarks on <see cref="GameContentManagerForAssetPropagation"/>.</summary> private readonly GameContentManagerForAssetPropagation DisposableContentManager; + /// <summary>Writes messages to the console.</summary> + private readonly IMonitor Monitor; + + /// <summary>Simplifies access to private game code.</summary> + private readonly Reflector Reflection; + /// <summary>Whether to enable more aggressive memory optimizations.</summary> private readonly bool AggressiveMemoryOptimizations; /// <summary>Normalizes an asset key to match the cache key and assert that it's valid.</summary> private readonly Func<string, string> AssertAndNormalizeAssetName; - /// <summary>Simplifies access to private game code.</summary> - private readonly Reflector Reflection; - /// <summary>Optimized bucket categories for batch reloading assets.</summary> private enum AssetBucket { @@ -65,12 +69,14 @@ namespace StardewModdingAPI.Metadata /// <summary>Initialize the core asset data.</summary> /// <param name="mainContent">The main content manager through which to reload assets.</param> /// <param name="disposableContent">An internal content manager used only for asset propagation.</param> + /// <param name="monitor">Writes messages to the console.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> - public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, Reflector reflection, bool aggressiveMemoryOptimizations) + public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, bool aggressiveMemoryOptimizations) { this.MainContentManager = mainContent; this.DisposableContentManager = disposableContent; + this.Monitor = monitor; this.Reflection = reflection; this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; @@ -116,7 +122,17 @@ namespace StardewModdingAPI.Metadata default: foreach (var entry in bucket) { - bool changed = this.PropagateOther(entry.Key, entry.Value, ignoreWorld, out bool curChangedMapWarps); + bool changed = false; + bool curChangedMapWarps = false; + try + { + changed = this.PropagateOther(entry.Key, entry.Value, ignoreWorld, out curChangedMapWarps); + } + catch (Exception ex) + { + this.Monitor.Log($"An error occurred while propagating asset changes. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + propagatedAssets[entry.Key] = changed; updatedNpcWarps = updatedNpcWarps || curChangedMapWarps; } @@ -258,6 +274,7 @@ namespace StardewModdingAPI.Metadata return true; case "data\\bundles": // NetWorldState constructor + if (Context.IsMainPlayer && Game1.netWorldState != null) { var bundles = this.Reflection.GetField<NetBundles>(Game1.netWorldState.Value, "bundles").GetValue(); var rewards = this.Reflection.GetField<NetIntDictionary<bool, NetBool>>(Game1.netWorldState.Value, "bundleRewards").GetValue(); @@ -286,6 +303,10 @@ namespace StardewModdingAPI.Metadata Game1.clothingInformation = content.Load<Dictionary<int, string>>(key); return true; + case "data\\concessions": // MovieTheater.GetConcessions + MovieTheater.ClearCachedLocalizedData(); + return true; + case "data\\concessiontastes": // MovieTheater.GetConcessionTasteForCharacter this.Reflection .GetField<List<ConcessionTaste>>(typeof(MovieTheater), "_concessionTastes") @@ -306,16 +327,9 @@ namespace StardewModdingAPI.Metadata case "data\\hairdata": // Farmer.GetHairStyleMetadataFile return this.ReloadHairData(); - case "data\\moviesreactions": // MovieTheater.GetMovieReactions - this.Reflection - .GetField<List<MovieCharacterReaction>>(typeof(MovieTheater), "_genericReactions") - .SetValue(content.Load<List<MovieCharacterReaction>>(key)); - return true; - case "data\\movies": // MovieTheater.GetMovieData - this.Reflection - .GetField<Dictionary<string, MovieData>>(typeof(MovieTheater), "_movieData") - .SetValue(content.Load<Dictionary<string, MovieData>>(key)); + case "data\\moviesreactions": // MovieTheater.GetMovieReactions + MovieTheater.ClearCachedLocalizedData(); return true; case "data\\npcdispositions": // NPC constructor diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 986d2780..e830f799 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -24,6 +24,8 @@ namespace StardewModdingAPI /// <param name="args">The command-line arguments.</param> public static void Main(string[] args) { + Console.Title = $"SMAPI {EarlyConstants.RawApiVersion}{(EarlyConstants.IsWindows64BitHack ? " 64-bit" : "")} - {Console.Title}"; + try { AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; @@ -159,7 +161,7 @@ namespace StardewModdingAPI Console.ResetColor(); Console.WriteLine(); } - + Program.PressAnyKeyToExit(showMessage: true); } diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 034eceed..7b5625d6 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -70,8 +70,13 @@ copy all the settings, or you may cause bugs due to overridden changes in future "GitHubProjectName": "Pathoschild/SMAPI", /** + * Stardew64Installer's GitHub project name, used to perform update checks. + */ + "Stardew64InstallerGitHubProjectName": "Steviegt6/Stardew64Installer", + + /** * The base URL for SMAPI's web API, used to perform update checks. - * Note: the protocol will be changed to http:// on Linux/Mac due to OpenSSL issues with the + * Note: the protocol will be changed to http:// on Linux/macOS due to OpenSSL issues with the * game's bundled Mono. */ "WebApiBaseUrl": "https://smapi.io/api/", @@ -85,7 +90,7 @@ copy all the settings, or you may cause bugs due to overridden changes in future * The colors to use for text written to the SMAPI console. * * The possible values for 'UseScheme' are: - * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color + * - AutoDetect: SMAPI will assume a light background on macOS, and detect the background color * automatically on Linux or Windows. * - LightBackground: use darker text colors that look better on a white or light background. * - DarkBackground: use lighter text colors that look better on a black or dark background. diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index ceef33df..413d9f33 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -14,6 +14,10 @@ <Import Project="..\..\build\common.targets" /> + <PropertyGroup Condition="$(DefineConstants.Contains(SMAPI_FOR_WINDOWS_64BIT_HACK))"> + <PlatformTarget>x64</PlatformTarget> + </PropertyGroup> + <ItemGroup> <PackageReference Include="LargeAddressAware" Version="1.0.5" /> <PackageReference Include="Mono.Cecil" Version="0.11.3" /> @@ -34,7 +38,7 @@ <!-- Windows only --> <ItemGroup Condition="'$(OS)' == 'Windows_NT'"> - <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" /> + <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" Condition="!$(DefineConstants.Contains(SMAPI_FOR_WINDOWS_64BIT_HACK))" /> <Reference Include="System.Windows.Forms" /> </ItemGroup> |