diff options
Diffstat (limited to 'src')
26 files changed, 415 insertions, 192 deletions
diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 88412d92..3508a6db 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -7,7 +7,11 @@ using System.Reflection; using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using Newtonsoft.Json; using StardewModdingAPI.ModBuildConfig.Framework; +using StardewModdingAPI.Toolkit.Framework; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig @@ -75,9 +79,41 @@ namespace StardewModdingAPI.ModBuildConfig this.Log.LogMessage(MessageImportance.High, $"[mod build package] Handling build with options {string.Join(", ", properties)}"); } + // skip if nothing to do + // (This must be checked before the manifest validation, to allow cases like unit test projects.) if (!this.EnableModDeploy && !this.EnableModZip) - return true; // nothing to do + return true; + + // validate the manifest file + IManifest manifest; + { + try + { + string manifestPath = Path.Combine(this.ProjectDir, "manifest.json"); + if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) + { + this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist."); + return false; + } + manifest = rawManifest; + } + catch (JsonReaderException ex) + { + // log the inner exception, otherwise the message will be generic + Exception exToShow = ex.InnerException ?? ex; + this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}"); + return false; + } + + // validate manifest fields + if (!ManifestValidator.TryValidateFields(manifest, out string error)) + { + this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}"); + return false; + } + } + // deploy files try { // parse extra DLLs to bundle @@ -101,7 +137,7 @@ namespace StardewModdingAPI.ModBuildConfig // create release zip if (this.EnableModZip) { - string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {package.GetManifestVersion()}.zip"); + string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); string zipPath = Path.Combine(this.ModZipPath, zipName); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index 80955f67..00f3f439 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework @@ -113,16 +111,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework return new Dictionary<string, FileInfo>(this.Files, StringComparer.OrdinalIgnoreCase); } - /// <summary>Get a semantic version from the mod manifest.</summary> - /// <exception cref="UserErrorException">The manifest is missing or invalid.</exception> - public string GetManifestVersion() - { - if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest)) - throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor - - return manifest.Version.ToString(); - } - /********* ** Private methods diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index ecac30bf..d1296dbe 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.17.1", + "Version": "3.17.2", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.17.1" + "MinimumApiVersion": "3.17.2" } diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index 3d708876..c3757e8f 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.17.1", + "Version": "3.17.2", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.17.1" + "MinimumApiVersion": "3.17.2" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index c2bf5f05..78821a3e 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.17.1", + "Version": "3.17.2", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.17.1" + "MinimumApiVersion": "3.17.2" } diff --git a/src/SMAPI.Tests/Core/AssumptionTests.cs b/src/SMAPI.Tests/Core/AssumptionTests.cs new file mode 100644 index 00000000..efc9da3f --- /dev/null +++ b/src/SMAPI.Tests/Core/AssumptionTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using FluentAssertions.Execution; +using NUnit.Framework; +using StardewModdingAPI.Framework.Models; + +namespace SMAPI.Tests.Core +{ + /// <summary>Unit tests which validate assumptions about .NET used in the SMAPI implementation.</summary> + [TestFixture] + internal class AssumptionTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that {nameof(HashSet<string>)} maintains insertion order when no elements are removed. If this fails, we'll need to change the implementation for the {nameof(SConfig.ModsToLoadEarly)} and {nameof(SConfig.ModsToLoadLate)} options.")] + [TestCase("construct from array")] + [TestCase("add incrementally")] + public void HashSet_MaintainsInsertionOrderWhenNoElementsAreRemoved(string populateMethod) + { + // arrange + string[] inserted = Enumerable.Range(0, 1000) + .Select(_ => Guid.NewGuid().ToString("N")) + .ToArray(); + + // act + HashSet<string> set; + switch (populateMethod) + { + case "construct from array": + set = new(inserted, StringComparer.OrdinalIgnoreCase); + break; + + case "add incrementally": + set = new(StringComparer.OrdinalIgnoreCase); + foreach (string value in inserted) + set.Add(value); + break; + + default: + throw new AssertionFailedException($"Unknown populate method '{populateMethod}'."); + } + + // assert + string[] actualOrder = set.ToArray(); + actualOrder.Should().HaveCount(inserted.Length); + for (int i = 0; i < inserted.Length; i++) + { + string expected = inserted[i]; + string actual = actualOrder[i]; + + if (actual != expected) + throw new AssertionFailedException($"The hash set differed at index {i}: expected {expected}, but found {actual} instead."); + } + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 1d518738..88142805 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -261,39 +261,50 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning /// <returns>The game directory, if found.</returns> private string? GetPathFromSteamLibrary(string? steamPath) { - if (steamPath == null) - return null; - - // get .vdf file path - string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf"); - if (!File.Exists(libraryFoldersPath)) - return null; - - // read data - using FileStream fileStream = File.OpenRead(libraryFoldersPath); - VdfDeserializer deserializer = new(); - dynamic libraries = deserializer.Deserialize(fileStream); - if (libraries?.libraryfolders is null) - return null; - - // get path from Stardew Valley app (if any) - foreach (dynamic pair in libraries.libraryfolders) + try { - dynamic library = pair.Value; - - foreach (dynamic app in library.apps) + if (steamPath == null) + return null; + + // get .vdf file path + string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf"); + if (!File.Exists(libraryFoldersPath)) + return null; + + // read data + using FileStream fileStream = File.OpenRead(libraryFoldersPath); + VdfDeserializer deserializer = new(); + dynamic libraries = deserializer.Deserialize(fileStream); + if (libraries?.libraryfolders is null) + return null; + + // get path from Stardew Valley app (if any) + foreach (dynamic pair in libraries.libraryfolders) { - string key = app.Key; - if (key == GameScanner.SteamAppId) + dynamic library = pair.Value; + + foreach (dynamic app in library.apps) { - string path = library.path; + string key = app.Key; + if (key == GameScanner.SteamAppId) + { + string path = library.path; - return Path.Combine(path.Replace("\\\\", "\\"), "steamapps", "common", "Stardew Valley"); + return Path.Combine(path.Replace("\\\\", "\\"), "steamapps", "common", "Stardew Valley"); + } } } - } - return null; + return null; + } + catch + { + // The file might not be parseable in some cases (e.g. some players have an older Steam version using + // a different format). Ideally we'd log an error to know when it's actually an issue, but the SMAPI + // installer doesn't have a logging mechanism (and third-party code calling the toolkit may not either). + // So for now, just ignore the error and fallback to the other discovery mechanisms. + return null; + } } #endif } diff --git a/src/SMAPI.Toolkit/Framework/ManifestValidator.cs b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs new file mode 100644 index 00000000..461dc325 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ManifestValidator.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Toolkit.Framework +{ + /// <summary>Validates manifest fields.</summary> + public static class ManifestValidator + { + /// <summary>Validate a manifest's fields.</summary> + /// <param name="manifest">The manifest to validate.</param> + /// <param name="error">The error message indicating why validation failed, if applicable.</param> + /// <returns>Returns whether all manifest fields validated successfully.</returns> + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "This is the method that ensures those annotations are respected.")] + public static bool TryValidateFields(IManifest manifest, out string error) + { + // + // Note: SMAPI assumes that it can grammatically append the returned sentence in the + // form "failed loading <mod> because its <error>". Any errors returned should be valid + // in that format, unless the SMAPI call is adjusted accordingly. + // + + bool hasDll = !string.IsNullOrWhiteSpace(manifest.EntryDll); + bool isContentPack = manifest.ContentPackFor != null; + + // validate use of EntryDll vs ContentPackFor fields + if (hasDll == isContentPack) + { + error = hasDll + ? $"manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive." + : $"manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."; + return false; + } + + // validate EntryDll/ContentPackFor format + if (hasDll) + { + if (manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) + { + error = $"manifest has invalid filename '{manifest.EntryDll}' for the {nameof(IManifest.EntryDll)} field."; + return false; + } + } + else + { + if (string.IsNullOrWhiteSpace(manifest.ContentPackFor!.UniqueID)) + { + error = $"manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."; + return false; + } + } + + // validate required fields + { + List<string> missingFields = new List<string>(3); + + if (string.IsNullOrWhiteSpace(manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (manifest.Version == null || manifest.Version.ToString() == "0.0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) + { + error = $"manifest is missing required fields ({string.Join(", ", missingFields)})."; + return false; + } + } + + // validate ID format + if (!PathUtilities.IsSlug(manifest.UniqueID)) + { + error = "manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + return false; + } + + // validate dependency format + foreach (IManifestDependency? dependency in manifest.Dependencies) + { + if (dependency == null) + { + error = $"manifest has a null entry under {nameof(IManifest.Dependencies)}."; + return false; + } + + if (string.IsNullOrWhiteSpace(dependency.UniqueID)) + { + error = $"manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."; + return false; + } + + if (!PathUtilities.IsSlug(dependency.UniqueID)) + { + error = $"manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."; + return false; + } + } + + error = ""; + return true; + } + } +} diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 5e0dedf3..c39e612b 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -108,6 +108,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } } + // detect split-screen mode + if (message.ScreenId != 0) + log.IsSplitScreen = true; + // collect SMAPI metadata if (message.Mod == "SMAPI") { diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index cda0f653..2a2e5f5c 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -22,6 +22,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models /// <summary>The raw log text.</summary> public string? RawText { get; set; } + /// <summary>Whether there are messages from multiple screens in the log.</summary> + public bool IsSplitScreen { get; set; } + /**** ** Log data ****/ diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 28127903..c1251c21 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -47,7 +47,7 @@ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js"></script> - <script src="~/Content/js/log-parser.js?r=20220409"></script> + <script src="~/Content/js/log-parser.js"></script> <script id="serializedData" type="application/json"> @if (!Model.ShowRaw) @@ -86,7 +86,8 @@ showMods: @this.ForJson(log?.Mods.Where(p => p.Loaded && !p.IsContentPack).Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)), showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), showLevels: @this.ForJson(defaultFilters), - enableFilters: @this.ForJson(!Model.ShowRaw) + enableFilters: @this.ForJson(!Model.ShowRaw), + isSplitScreen: @this.ForJson(log?.IsSplitScreen ?? false) } ); @@ -494,7 +495,6 @@ else if (log?.IsValid == true) <log-line v-for="msg in visibleMessages" v-bind:key="msg.id" - v-bind:showScreenId="showScreenId" v-bind:message="msg" v-bind:highlight="shouldHighlight" /> diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index fccd00be..324218bb 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -424,10 +424,6 @@ smapi.logParser = function (state) { Vue.component("log-line", { functional: true, props: { - showScreenId: { - type: Boolean, - required: true - }, message: { type: Object, required: true @@ -456,7 +452,7 @@ smapi.logParser = function (state) { "td", { attrs: { - colspan: context.props.showScreenId ? 4 : 3 + colspan: state.isSplitScreen ? 4 : 3 } }, "" @@ -541,7 +537,7 @@ smapi.logParser = function (state) { }, [ createElement("td", message.Time), - context.props.showScreenId ? createElement("td", message.ScreenId) : null, + state.isSplitScreen ? createElement("td", { attrs: { title: (message.ScreenId == 0 ? "main screen" : "screen #" + (message.ScreenId + 1)) + " in split-screen mode" } }, `🖵${message.ScreenId + 1}`) : null, createElement("td", level.toUpperCase()), createElement( "td", @@ -588,9 +584,6 @@ smapi.logParser = function (state) { anyModsShown: function () { return stats.modsShown > 0; }, - showScreenId: function () { - return this.data.screenIds.length > 1; - }, // Maybe not strictly necessary, but the Vue template is being // weird about accessing data entries on the app rather than diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index ea41a4ee..4cb30dc9 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -52,7 +52,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.17.1"; + internal static string RawApiVersion = "3.17.2"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -71,7 +71,7 @@ namespace StardewModdingAPI public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The maximum supported version of Stardew Valley, if any.</summary> - public static ISemanticVersion? MaximumGameVersion { get; } = null; + public static ISemanticVersion? MaximumGameVersion { get; } = new GameVersion("1.5.6"); /// <summary>The target game platform.</summary> public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; @@ -90,7 +90,7 @@ namespace StardewModdingAPI source: null, nounPhrase: $"{nameof(Constants)}.{nameof(Constants.ExecutionPath)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return Constants.GamePath; diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 241c09a8..7c8cc6a8 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -192,7 +192,7 @@ namespace StardewModdingAPI.Framework.Content int topOffset = startIndex / sourceArea.Width; int bottomOffset = endIndex / sourceArea.Width; - targetArea = new(targetArea.X, targetArea.Y + topOffset, targetArea.Width, bottomOffset - topOffset + 1); + targetArea = new(targetArea.X, targetArea.Y + topOffset - startRow, targetArea.Width, bottomOffset - topOffset + 1); pixelCount = targetArea.Width * targetArea.Height; sourceOffset = topOffset * sourceArea.Width; } diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index af000300..52ef02e6 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Content source: null, nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, unlessStackIncludes: new[] { $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index cf26307f..86415a5f 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -129,6 +129,7 @@ namespace StardewModdingAPI.Framework /// <param name="rootDirectory">The root directory to search for content.</param> /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="multiplayer">The multiplayer instance whose map cache to update during asset propagation.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> @@ -136,7 +137,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, AssetOperationGroup?> requestAssetOperations) + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Multiplayer multiplayer, 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)); @@ -177,7 +178,7 @@ namespace StardewModdingAPI.Framework this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, name => this.ParseAssetName(name, allowLocales: true)); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, multiplayer, reflection, name => this.ParseAssetName(name, allowLocales: true)); this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty<ModLanguage>())); } diff --git a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs index 5a5850d1..f44b5d7d 100644 --- a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs +++ b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.Deprecations foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) { // build message - string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the upcoming SMAPI 4.0.0."; + string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase}) and will break in the next major SMAPI update."; // get log level LogLevel level; diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index b7d4861f..90edc137 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.ConsoleCommands)}.{nameof(ICommandHelper.Trigger)}", version: "3.8.1", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.CommandManager.Trigger(name, arguments); diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 0a1633bf..152b264c 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -62,7 +62,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetLoaders)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.ObservableAssetLoaders; @@ -78,7 +78,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetEditors)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.ObservableAssetEditors; diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 1cdd8536..531289d0 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -41,7 +41,7 @@ namespace StardewModdingAPI.Framework.ModHelpers source: this.Mod, nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.Content)}", version: "3.14.0", - severity: DeprecationLevel.Info + severity: DeprecationLevel.PendingRemoval ); return this.ContentImpl; diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index fe56f4d2..96975e05 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -4,11 +4,11 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Toolkit.Serialization.Models; -using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities.PathLookups; namespace StardewModdingAPI.Framework.ModLoading @@ -126,101 +126,24 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // validate DLL / content pack fields + // validate manifest format + if (!ManifestValidator.TryValidateFields(mod.Manifest, out string manifestError)) { - bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll); - bool isContentPack = mod.Manifest.ContentPackFor != null; - - // validate field presence - if (!hasDll && !isContentPack) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); - continue; - } - if (hasDll && isContentPack) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); - continue; - } - - // validate DLL - if (hasDll) - { - // invalid filename format - if (mod.Manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); - continue; - } - - // file doesn't exist - if (validateFilesExist) - { - IFileLookup pathLookup = getFileLookup(mod.DirectoryPath); - FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!); - if (!file.Exists) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); - continue; - } - } - } - - // validate content pack - else - { - // invalid content pack ID - if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor!.UniqueID)) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); - continue; - } - } + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {manifestError}"); + continue; } - // validate required fields + // check that DLL exists if applicable + if (!string.IsNullOrEmpty(mod.Manifest.EntryDll) && validateFilesExist) { - List<string> missingFields = new List<string>(3); - - if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) - missingFields.Add(nameof(IManifest.Name)); - if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0.0") - missingFields.Add(nameof(IManifest.Version)); - if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) - missingFields.Add(nameof(IManifest.UniqueID)); - - if (missingFields.Any()) + IFileLookup pathLookup = getFileLookup(mod.DirectoryPath); + FileInfo file = pathLookup.GetFile(mod.Manifest.EntryDll!); + if (!file.Exists) { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); continue; } } - - // validate ID format - if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); - - // validate dependencies - foreach (IManifestDependency? dependency in mod.Manifest.Dependencies) - { - // null dependency - if (dependency == null) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a null entry under {nameof(IManifest.Dependencies)}."); - continue; - } - - // missing ID - if (string.IsNullOrWhiteSpace(dependency.UniqueID)) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with no {nameof(IManifestDependency.UniqueID)} field."); - continue; - } - - // invalid ID - if (!PathUtilities.IsSlug(dependency.UniqueID)) - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has a {nameof(IManifest.Dependencies)} entry with an invalid {nameof(IManifestDependency.UniqueID)} field (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); - } } // validate IDs are unique @@ -242,10 +165,32 @@ namespace StardewModdingAPI.Framework.ModLoading } } + /// <summary>Apply preliminary overrides to the load order based on the SMAPI configuration.</summary> + /// <param name="mods">The mods to process.</param> + /// <param name="modIdsToLoadEarly">The mod IDs SMAPI should load before any other mods (except those needed to load them).</param> + /// <param name="modIdsToLoadLate">The mod IDs SMAPI should load after any other mods.</param> + public IModMetadata[] ApplyLoadOrderOverrides(IModMetadata[] mods, HashSet<string> modIdsToLoadEarly, HashSet<string> modIdsToLoadLate) + { + if (!modIdsToLoadEarly.Any() && !modIdsToLoadLate.Any()) + return mods; + + return mods + .OrderBy(mod => + { + string id = mod.Manifest.UniqueID; + if (modIdsToLoadEarly.Contains(id)) + return -1; + if (modIdsToLoadLate.Contains(id)) + return 1; + return 0; + }) + .ToArray(); + } + /// <summary>Sort the given mods by the order they should be loaded.</summary> /// <param name="mods">The mods to process.</param> /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> - public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods, ModDatabase modDatabase) + public IEnumerable<IModMetadata> ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase) { // initialize metadata mods = mods.ToArray(); @@ -261,7 +206,7 @@ namespace StardewModdingAPI.Framework.ModLoading // sort mods foreach (IModMetadata mod in mods) - this.ProcessDependencies(mods.ToArray(), modDatabase, mod, states, sortedMods, new List<IModMetadata>()); + this.ProcessDependencies(mods, modDatabase, mod, states, sortedMods, new List<IModMetadata>()); return sortedMods.Reverse(); } @@ -278,7 +223,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param> /// <param name="currentChain">The current change of mod dependencies.</param> /// <returns>Returns the mod dependency status.</returns> - private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) + private ModDependencyStatus ProcessDependencies(IReadOnlyList<IModMetadata> mods, ModDatabase modDatabase, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) { // check if already visited switch (states[mod]) @@ -409,7 +354,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Get the dependencies declared in a manifest.</summary> /// <param name="manifest">The mod manifest.</param> /// <param name="loadedMods">The loaded mods.</param> - private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) + private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IReadOnlyList<IModMetadata> loadedMods) { IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index bceb0940..87970e6c 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -16,6 +16,7 @@ namespace StardewModdingAPI.Framework.Models private static readonly IDictionary<string, object> DefaultValues = new Dictionary<string, object> { [nameof(CheckForUpdates)] = true, + [nameof(ListenForConsoleInput)] = true, [nameof(ParanoidWarnings)] = Constants.IsDebugBuild, [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", @@ -48,6 +49,9 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> public bool CheckForUpdates { get; set; } + /// <summary>Whether SMAPI should listen for console input to support console commands.</summary> + public bool ListenForConsoleInput { get; set; } + /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> public bool ParanoidWarnings { get; set; } @@ -82,28 +86,38 @@ namespace StardewModdingAPI.Framework.Models /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary> public HashSet<string> SuppressUpdateChecks { get; set; } + /// <summary>The mod IDs SMAPI should load before any other mods (except those needed to load them).</summary> + public HashSet<string> ModsToLoadEarly { get; set; } + + /// <summary>The mod IDs SMAPI should load after any other mods.</summary> + public HashSet<string> ModsToLoadLate { get; set; } + /******** ** Public methods ********/ /// <summary>Construct an instance.</summary> - /// <param name="developerMode">Whether to enable development features.</param> - /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param> - /// <param name="paranoidWarnings">Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</param> - /// <param name="useBetaChannel">Whether to show beta versions as valid updates.</param> - /// <param name="gitHubProjectName">SMAPI's GitHub project name, used to perform update checks.</param> - /// <param name="webApiBaseUrl">The base URL for SMAPI's web API, used to perform update checks.</param> - /// <param name="verboseLogging">The log contexts for which to enable verbose logging, which may show a lot more information to simplify troubleshooting.</param> - /// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param> - /// <param name="useCaseInsensitivePaths">>Whether to make SMAPI file APIs case-insensitive, even on Linux.</param> - /// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param> - /// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param> - /// <param name="suppressHarmonyDebugMode">Whether to prevent mods from enabling Harmony's debug mode, which impacts performance and creates a file on your desktop. Debug mode should never be enabled by a released mod.</param> - /// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param> - public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks) + /// <param name="developerMode"><inheritdoc cref="DeveloperMode" path="/summary" /></param> + /// <param name="checkForUpdates"><inheritdoc cref="CheckForUpdates" path="/summary" /></param> + /// <param name="listenForConsoleInput"><inheritdoc cref="ListenForConsoleInput" path="/summary" /></param> + /// <param name="paranoidWarnings"><inheritdoc cref="ParanoidWarnings" path="/summary" /></param> + /// <param name="useBetaChannel"><inheritdoc cref="UseBetaChannel" path="/summary" /></param> + /// <param name="gitHubProjectName"><inheritdoc cref="GitHubProjectName" path="/summary" /></param> + /// <param name="webApiBaseUrl"><inheritdoc cref="WebApiBaseUrl" path="/summary" /></param> + /// <param name="verboseLogging"><inheritdoc cref="VerboseLogging" path="/summary" /></param> + /// <param name="rewriteMods"><inheritdoc cref="RewriteMods" path="/summary" /></param> + /// <param name="useCaseInsensitivePaths"><inheritdoc cref="UseCaseInsensitivePaths" path="/summary" /></param> + /// <param name="logNetworkTraffic"><inheritdoc cref="LogNetworkTraffic" path="/summary" /></param> + /// <param name="consoleColors"><inheritdoc cref="ConsoleColors" path="/summary" /></param> + /// <param name="suppressHarmonyDebugMode"><inheritdoc cref="SuppressHarmonyDebugMode" path="/summary" /></param> + /// <param name="suppressUpdateChecks"><inheritdoc cref="SuppressUpdateChecks" path="/summary" /></param> + /// <param name="modsToLoadEarly"><inheritdoc cref="ModsToLoadEarly" path="/summary" /></param> + /// <param name="modsToLoadLate"><inheritdoc cref="ModsToLoadLate" path="/summary" /></param> + public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate) { this.DeveloperMode = developerMode; this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)]; + this.ListenForConsoleInput = listenForConsoleInput ?? (bool)SConfig.DefaultValues[nameof(this.ListenForConsoleInput)]; this.ParanoidWarnings = paranoidWarnings ?? (bool)SConfig.DefaultValues[nameof(this.ParanoidWarnings)]; this.UseBetaChannel = useBetaChannel ?? (bool)SConfig.DefaultValues[nameof(this.UseBetaChannel)]; this.GitHubProjectName = gitHubProjectName; @@ -115,6 +129,8 @@ namespace StardewModdingAPI.Framework.Models this.ConsoleColors = consoleColors; this.SuppressHarmonyDebugMode = suppressHarmonyDebugMode ?? (bool)SConfig.DefaultValues[nameof(this.SuppressHarmonyDebugMode)]; this.SuppressUpdateChecks = new HashSet<string>(suppressUpdateChecks ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); + this.ModsToLoadEarly = new HashSet<string>(modsToLoadEarly ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); + this.ModsToLoadLate = new HashSet<string>(modsToLoadLate ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase); } /// <summary>Override the value of <see cref="DeveloperMode"/>.</summary> @@ -136,6 +152,12 @@ namespace StardewModdingAPI.Framework.Models custom[name] = value; } + if (this.ModsToLoadEarly.Any()) + custom[nameof(this.ModsToLoadEarly)] = $"[{string.Join(", ", this.ModsToLoadEarly)}]"; + + if (this.ModsToLoadLate.Any()) + custom[nameof(this.ModsToLoadLate)] = $"[{string.Join(", ", this.ModsToLoadLate)}]"; + if (!this.SuppressUpdateChecks.SetEquals(SConfig.DefaultSuppressUpdateChecks)) custom[nameof(this.SuppressUpdateChecks)] = $"[{string.Join(", ", this.SuppressUpdateChecks)}]"; diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 40979b09..c977ad65 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -423,8 +423,29 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot)."); mods = mods.Where(p => !p.IsIgnored).ToArray(); - // load mods + // validate manifests resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl, getFileLookup: this.GetFileLookup); + + // apply load order customizations + if (this.Settings.ModsToLoadEarly.Any() || this.Settings.ModsToLoadLate.Any()) + { + HashSet<string> installedIds = new HashSet<string>(mods.Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase); + + string[] missingEarlyMods = this.Settings.ModsToLoadEarly.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray(); + string[] missingLateMods = this.Settings.ModsToLoadLate.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray(); + string[] duplicateMods = this.Settings.ModsToLoadLate.Where(id => this.Settings.ModsToLoadEarly.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray(); + + if (missingEarlyMods.Any()) + this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadEarly)} which aren't installed: '{string.Join("', '", missingEarlyMods)}'.", LogLevel.Warn); + if (missingLateMods.Any()) + this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadLate)} which aren't installed: '{string.Join("', '", missingLateMods)}'.", LogLevel.Warn); + if (duplicateMods.Any()) + this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs which are in both {nameof(this.Settings.ModsToLoadEarly)} and {nameof(this.Settings.ModsToLoadLate)}: '{string.Join("', '", duplicateMods)}'. These will be loaded early.", LogLevel.Warn); + + mods = resolver.ApplyLoadOrderOverrides(mods, this.Settings.ModsToLoadEarly, this.Settings.ModsToLoadLate); + } + + // load mods mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); @@ -447,14 +468,17 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // start SMAPI console - new Thread( - () => this.LogManager.RunConsoleInputLoop( - commandManager: this.CommandManager, - reloadTranslations: this.ReloadTranslations, - handleInput: input => this.RawCommandQueue.Add(input), - continueWhile: () => this.IsGameRunning && !this.IsExiting - ) - ).Start(); + if (this.Settings.ListenForConsoleInput) + { + new Thread( + () => this.LogManager.RunConsoleInputLoop( + commandManager: this.CommandManager, + reloadTranslations: this.ReloadTranslations, + handleInput: input => this.RawCommandQueue.Add(input), + continueWhile: () => this.IsGameRunning && !this.IsExiting + ) + ).Start(); + } } /// <summary>Raised after an instance finishes loading its initial content.</summary> @@ -1327,6 +1351,7 @@ namespace StardewModdingAPI.Framework rootDirectory: rootDirectory, currentCulture: Thread.CurrentThread.CurrentUICulture, monitor: this.Monitor, + multiplayer: this.Multiplayer, reflection: this.Reflection, jsonHelper: this.Toolkit.JsonHelper, onLoadingFirstAsset: this.InitializeBeforeFirstAssetLoaded, @@ -1712,7 +1737,7 @@ namespace StardewModdingAPI.Framework source: metadata, nounPhrase: $"{nameof(IAssetEditor)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, logStackTrace: false ); @@ -1725,7 +1750,7 @@ namespace StardewModdingAPI.Framework source: metadata, nounPhrase: $"{nameof(IAssetLoader)}", version: "3.14.0", - severity: DeprecationLevel.Info, + severity: DeprecationLevel.PendingRemoval, logStackTrace: false ); @@ -1757,7 +1782,7 @@ namespace StardewModdingAPI.Framework metadata, $"using {name} without bundling it", "3.14.7", - DeprecationLevel.Info, + DeprecationLevel.PendingRemoval, logStackTrace: false ); } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 1ef9a8f2..037e4573 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -40,6 +40,9 @@ namespace StardewModdingAPI.Metadata /// <summary>Writes messages to the console.</summary> private readonly IMonitor Monitor; + /// <summary>The multiplayer instance whose map cache to update.</summary> + private readonly Multiplayer Multiplayer; + /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; @@ -70,13 +73,15 @@ namespace StardewModdingAPI.Metadata /// <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="multiplayer">The multiplayer instance whose map cache to update.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="parseAssetName">Parse a raw asset name.</param> - public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, Func<string, IAssetName> parseAssetName) + public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Multiplayer multiplayer, Reflector reflection, Func<string, IAssetName> parseAssetName) { this.MainContentManager = mainContent; this.DisposableContentManager = disposableContent; this.Monitor = monitor; + this.Multiplayer = multiplayer; this.Reflection = reflection; this.ParseAssetName = parseAssetName; } @@ -1166,6 +1171,10 @@ namespace StardewModdingAPI.Metadata GameLocation location = locationInfo.Location; Vector2? playerPos = Game1.player?.Position; + // clear multiplayer cache for farmhands + if (!Context.IsMainPlayer) + this.Multiplayer.cachedMultiplayerMaps.Remove(location.NameOrUniqueName); + // reload map location.interiorDoors.Clear(); // prevent errors when doors try to update tiles which no longer exist location.reloadMap(); diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 635e3add..0d00db4d 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -42,6 +42,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future "DeveloperMode": true, /** + * Whether SMAPI should listen for console input. Disabling this will prevent you from using + * console commands. On some specific Linux systems, disabling this may reduce CPU usage. + */ + "ListenForConsoleInput": true, + + /** * Whether SMAPI should rewrite mods for compatibility. This may prevent older mods from * loading, but bypasses a Visual Studio crash when debugging. */ @@ -141,5 +147,17 @@ copy all the settings, or you may cause bugs due to overridden changes in future "SMAPI.ConsoleCommands", "SMAPI.ErrorHandler", "SMAPI.SaveBackup" - ] + ], + + /** + * The mod IDs SMAPI should load before any other mods (except those needed to load them) + * or after any other mods. + * + * This lets you manually fix the load order if needed, but this is a last resort — SMAPI + * automatically adjusts the load order based on mods' dependencies, so needing to manually + * edit the order is usually a problem with one or both mods' metadata that can be reported to + * the mod author. + */ + "ModsToLoadEarly": [], + "ModsToLoadLate": [] } diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 468df0bd..87bf2027 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Utilities null, $"calling the {nameof(PerScreen<T>)} constructor with null", "3.14.0", - DeprecationLevel.Info + DeprecationLevel.PendingRemoval ); #else throw new ArgumentNullException(nameof(createNewState)); |