From ad1b9a870b5383ca9ada8c52b2bd76960d5579da Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 23 Aug 2020 14:22:27 -0400 Subject: move some console/logging logic out of SCore into a new LogManager --- src/SMAPI/Framework/Logging/LogManager.cs | 586 ++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 src/SMAPI/Framework/Logging/LogManager.cs (limited to 'src/SMAPI/Framework/Logging') diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs new file mode 100644 index 00000000..3786e940 --- /dev/null +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -0,0 +1,586 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using StardewModdingAPI.Framework.Commands; +using StardewModdingAPI.Internal.ConsoleWriting; +using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Framework.Logging +{ + /// Manages the SMAPI console window and log file. + internal class LogManager : IDisposable + { + /********* + ** Fields + *********/ + /// The log file to which to write messages. + private readonly LogFileManager LogFile; + + /// Manages console output interception. + private readonly ConsoleInterceptionManager InterceptionManager = new ConsoleInterceptionManager(); + + /// Get a named monitor instance. + private readonly Func GetMonitorImpl; + + /// Regex patterns which match console non-error messages to suppress from the console and log. + private readonly Regex[] SuppressConsolePatterns = + { + new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) + }; + + /// Regex patterns which match console messages to show a more friendly error for. + private readonly ReplaceLogPattern[] ReplaceConsolePatterns = + { + // Steam not loaded + new ReplaceLogPattern( + search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + replacement: +#if SMAPI_FOR_WINDOWS + "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).", +#else + "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.", +#endif + logLevel: LogLevel.Error + ), + + // save file not found error + new ReplaceLogPattern( + search: new Regex(@"^System\.IO\.FileNotFoundException: [^\n]+\n[^:]+: '[^\n]+[/\\]Saves[/\\]([^'\r\n]+)[/\\]([^'\r\n]+)'[\s\S]+LoadGameMenu\.FindSaveGames[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + replacement: "The game can't find the '$2' file for your '$1' save. See https://stardewvalleywiki.com/Saves#Troubleshooting for help.", + logLevel: LogLevel.Error + ) + }; + + + /********* + ** Accessors + *********/ + /// The core logger and monitor for SMAPI. + public Monitor Monitor { get; } + + /// The core logger and monitor on behalf of the game. + public Monitor MonitorForGame { get; } + + + /********* + ** Public methods + *********/ + /**** + ** Initialization + ****/ + /// Construct an instance. + /// The log file path to write. + /// The colors to use for text written to the SMAPI console. + /// Whether to output log messages to the console. + /// Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed. + /// Whether to enable full console output for developers. + public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode) + { + // init construction logic + this.GetMonitorImpl = name => new Monitor(name, this.InterceptionManager, this.LogFile, colorConfig, isVerbose) + { + WriteToConsole = writeToConsole, + ShowTraceInConsole = isDeveloperMode, + ShowFullStampInConsole = isDeveloperMode + }; + + // init fields + this.LogFile = new LogFileManager(logPath); + this.Monitor = this.GetMonitor("SMAPI"); + this.MonitorForGame = this.GetMonitor("game"); + + // redirect direct console output + if (this.MonitorForGame.WriteToConsole) + this.InterceptionManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + } + + /// Get a monitor instance derived from SMAPI's current settings. + /// The name of the module which will log messages with this instance. + public Monitor GetMonitor(string name) + { + return this.GetMonitorImpl(name); + } + + /// Set the title of the SMAPI console window. + /// The new window title. + public void SetConsoleTitle(string title) + { + Console.Title = title; + } + + /**** + ** Console input + ****/ + /// Run a loop handling console input. + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + public void RunConsoleInputLoop(CommandManager commandManager, Action reloadTranslations, Action handleInput, Func continueWhile) + { + // prepare console + this.Monitor.Log("Type 'help' for help, or 'help ' for a command's usage", LogLevel.Info); + commandManager + .Add(new HelpCommand(commandManager), this.Monitor) + .Add(new HarmonySummaryCommand(), this.Monitor) + .Add(new ReloadI18nCommand(reloadTranslations), this.Monitor); + + // start handling command line input + Thread inputThread = new Thread(() => + { + while (true) + { + // get input + string input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + // handle command + this.Monitor.LogUserInput(input); + handleInput(input); + } + }); + inputThread.Start(); + + // keep console thread alive while the game is running + while (continueWhile()) + Thread.Sleep(1000 / 10); + if (inputThread.ThreadState == ThreadState.Running) + inputThread.Abort(); + } + + /// Show a 'press any key to exit' message, and exit when they press a key. + public void PressAnyKeyToExit() + { + this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + this.PressAnyKeyToExit(showMessage: false); + } + + /// Show a 'press any key to exit' message, and exit when they press a key. + /// Whether to print a 'press any key to exit' message to the console. + public void PressAnyKeyToExit(bool showMessage) + { + if (showMessage) + Console.WriteLine("Game has ended. Press any key to exit."); + Thread.Sleep(100); + Console.ReadKey(); + Environment.Exit(0); + } + + /**** + ** Crash/update handling + ****/ + /// Create a crash marker and duplicate the log into the crash log. + public void WriteCrashLog() + { + try + { + File.WriteAllText(Constants.FatalCrashMarker, string.Empty); + File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}", LogLevel.Error); + } + } + + /// Write an update alert marker file. + /// The new version found. + public void WriteUpdateMarker(string version) + { + File.WriteAllText(Constants.UpdateMarker, version); + } + + /// Check whether SMAPI crashed or detected an update during the last session, and display them in the SMAPI console. + public void HandleMarkerFiles() + { + // show update alert + if (File.Exists(Constants.UpdateMarker)) + { + string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); + if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) + { + if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) + { + this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); + this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); + this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); + Console.ReadKey(); + } + } + File.Delete(Constants.UpdateMarker); + } + + // show details if game crashed during last session + if (File.Exists(Constants.FatalCrashMarker)) + { + this.Monitor.Log("The game crashed last time you played. If it happens repeatedly, see 'get help' on https://smapi.io.", LogLevel.Error); + this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://smapi.io/log.", LogLevel.Error); + this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); + Console.ReadKey(); + File.Delete(Constants.FatalCrashLog); + File.Delete(Constants.FatalCrashMarker); + } + } + + /// Log a fatal exception which prevents SMAPI from launching. + /// The exception details. + public void LogFatalLaunchError(Exception exception) + { + switch (exception) + { + // audio crash + case InvalidOperationException ex when ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor"): + this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}"); + break; + + // missing content folder exception + case FileNotFoundException ex when ex.Message == "Could not find file 'C:\\Program Files (x86)\\Steam\\SteamApps\\common\\Stardew Valley\\Content\\XACT\\FarmerSounds.xgs'.": // path in error is hardcoded regardless of install path + this.Monitor.Log("The game can't find its Content\\XACT\\FarmerSounds.xgs file. You can usually fix this by resetting your content files (see https://smapi.io/troubleshoot#reset-content ), or by uninstalling and reinstalling the game.", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}"); + break; + + // generic exception + default: + this.MonitorForGame.Log($"The game failed to launch: {exception.GetLogSummary()}", LogLevel.Error); + break; + } + } + + /**** + ** General log output + ****/ + /// Log the initial header with general SMAPI and system details. + /// The path from which mods will be loaded. + /// The custom SMAPI settings. + public void LogIntro(string modsPath, IDictionary customSettings) + { + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {modsPath}", LogLevel.Info); + if (modsPath != Constants.DefaultModsPath) + this.Monitor.Log("(Using custom --mods-path argument.)"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC"); + + // log custom settings + if (customSettings.Any()) + this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}"); + } + + /// Log details for settings that don't match the default. + /// Whether to enable full console output for developers. + /// Whether to check for newer versions of SMAPI and mods on startup. + public void LogSettingsHeader(bool isDeveloperMode, bool checkForUpdates) + { + if (isDeveloperMode) + this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (!checkForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + this.Monitor.VerboseLog("Verbose logging enabled."); + } + + /// Log info about loaded mods. + /// The full list of loaded content packs and mods. + /// The loaded content packs. + /// The loaded mods. + /// The mods which could not be loaded. + /// Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access. + public void LogModInfo(IModMetadata[] loaded, IModMetadata[] loadedContentPacks, IModMetadata[] loadedMods, IModMetadata[] skippedMods, bool logParanoidWarnings) + { + // log loaded mods + this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + + this.Monitor.Newline(); + + // log loaded content packs + if (loadedContentPacks.Any()) + { + string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; + + this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); + foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + + this.Monitor.Newline(); + } + + // log mod warnings + this.LogModWarnings(loaded, skippedMods, logParanoidWarnings); + } + + /// + public void Dispose() + { + this.InterceptionManager.Dispose(); + this.LogFile.Dispose(); + } + + + /********* + ** Protected methods + *********/ + /// Redirect messages logged directly to the console to the given monitor. + /// The monitor with which to log messages as the game. + /// The message to log. + private void HandleConsoleMessage(IMonitor gameMonitor, string message) + { + // detect exception + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; + + // ignore suppressed message + if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) + return; + + // show friendly error if applicable + foreach (ReplaceLogPattern entry in this.ReplaceConsolePatterns) + { + string newMessage = entry.Search.Replace(message, entry.Replacement); + if (message != newMessage) + { + gameMonitor.Log(newMessage, entry.LogLevel); + gameMonitor.Log(message); + return; + } + } + + // forward to monitor + gameMonitor.Log(message, level); + } + + /// Write a summary of mod warnings to the console and log. + /// The loaded mods. + /// The mods which could not be loaded. + /// Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access. + private void LogModWarnings(IEnumerable mods, IModMetadata[] skippedMods, bool logParanoidWarnings) + { + // get mods with warnings + IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); + if (!modsWithWarnings.Any() && !skippedMods.Any()) + return; + + // log intro + { + int count = modsWithWarnings.Length + skippedMods.Length; + this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); + } + + // log skipped mods + if (skippedMods.Any()) + { + // get logging logic + void LogSkippedMod(IModMetadata mod) + { + string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {mod.Error}"; + + this.Monitor.Log(message, LogLevel.Error); + if (mod.ErrorDetails != null) + this.Monitor.Log($" ({mod.ErrorDetails})"); + } + + // find skipped dependencies + IModMetadata[] skippedDependencies; + { + HashSet skippedDependencyIds = new HashSet(StringComparer.OrdinalIgnoreCase); + HashSet skippedModIds = new HashSet(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); + } + skippedDependencies = skippedMods.Where(p => p.HasID() && skippedDependencyIds.Contains(p.Manifest.UniqueID)).ToArray(); + } + + // 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 skippedMods.OrderBy(p => p.DisplayName)) + LogSkippedMod(mod); + this.Monitor.Newline(); + } + + // log warnings + if (modsWithWarnings.Any()) + { + // broken code + this.LogModWarningGroup(modsWithWarnings, ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", + "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", + "errors, or crashes in-game." + ); + + // changes serializer + this.LogModWarningGroup(modsWithWarnings, ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + "These mods change the save serializer. They may corrupt your save files, or make them unusable if", + "you uninstall these mods." + ); + + // patched game code + this.LogModWarningGroup(modsWithWarnings, ModWarning.PatchesGame, LogLevel.Info, "Patched game code", + "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", + "your game has issues, try removing these first. Otherwise you can ignore this warning." + ); + + // unvalidated update tick + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", + "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", + "corruption. If your game has issues, try removing these first." + ); + + // paranoid warnings + if (logParanoidWarnings) + { + this.LogModWarningGroup( + modsWithWarnings, + match: mod => mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole, ModWarning.AccessesFilesystem, ModWarning.AccessesShell), + level: LogLevel.Debug, + heading: "Direct system access", + blurb: new[] + { + "You enabled paranoid warnings and these mods directly access the filesystem, shells/processes, or", + "SMAPI console. (This is usually legitimate and innocent usage; this warning is only useful for", + "further investigation.)" + }, + modLabel: mod => + { + List labels = new List(); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesConsole)) + labels.Add("console"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesFilesystem)) + labels.Add("files"); + if (mod.HasUnsuppressedWarnings(ModWarning.AccessesShell)) + labels.Add("shells/processes"); + + return $"{mod.DisplayName} ({string.Join(", ", labels)})"; + } + ); + } + + // no update keys + this.LogModWarningGroup(modsWithWarnings, ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", + "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", + "mods. Consider notifying the mod authors about this problem." + ); + + // not crossplatform + this.LogModWarningGroup(modsWithWarnings, ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", + "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." + ); + } + } + + /// Write a mod warning group to the console and log. + /// The mods to search. + /// Matches mods to include in the warning group. + /// The log level for the logged messages. + /// A brief heading label for the group. + /// A detailed explanation of the warning, split into lines. + /// Formats the mod label, or null to use the . + private void LogModWarningGroup(IModMetadata[] mods, Func match, LogLevel level, string heading, string[] blurb, Func modLabel = null) + { + // get matching mods + string[] modLabels = mods + .Where(match) + .Select(mod => modLabel?.Invoke(mod) ?? mod.DisplayName) + .OrderBy(p => p) + .ToArray(); + if (!modLabels.Any()) + return; + + // log header/blurb + this.Monitor.Log(" " + heading, level); + this.Monitor.Log(" " + "".PadRight(50, '-'), level); + foreach (string line in blurb) + this.Monitor.Log(" " + line, level); + this.Monitor.Newline(); + + // log mod list + foreach (string label in modLabels) + this.Monitor.Log($" - {label}", level); + + this.Monitor.Newline(); + } + + /// Write a mod warning group to the console and log. + /// The mods to search. + /// The mod warning to match. + /// The log level for the logged messages. + /// A brief heading label for the group. + /// A detailed explanation of the warning, split into lines. + private void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) + { + this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); + } + + + /********* + ** Protected types + *********/ + /// A console log pattern to replace with a different message. + private class ReplaceLogPattern + { + /********* + ** Accessors + *********/ + /// The regex pattern matching the portion of the message to replace. + public Regex Search { get; } + + /// The replacement string. + public string Replacement { get; } + + /// The log level for the new message. + public LogLevel LogLevel { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The regex pattern matching the portion of the message to replace. + /// The replacement string. + /// The log level for the new message. + public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel) + { + this.Search = search; + this.Replacement = replacement; + this.LogLevel = logLevel; + } + } + } +} -- cgit From 046deb2d56b6d4665280cc5478d9e683ec1d777d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 24 Aug 2020 19:25:57 -0400 Subject: simplify console interception flow The console interceptor now uses a marker in the string (instead of a state field) to track whether the message should intercepted. This makes each write more atomic, so it's less affected by multithreading in some cases. --- docs/release-notes.md | 4 +- .../Logging/ConsoleInterceptionManager.cs | 59 ---------------------- .../Framework/Logging/InterceptingTextWriter.cs | 55 +++++++++++++------- src/SMAPI/Framework/Logging/LogManager.cs | 15 +++--- src/SMAPI/Framework/Monitor.cs | 19 +++---- 5 files changed, 56 insertions(+), 96 deletions(-) delete mode 100644 src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs (limited to 'src/SMAPI/Framework/Logging') diff --git a/docs/release-notes.md b/docs/release-notes.md index 2282bc3d..a65db68c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,11 +9,12 @@ ## Upcoming release * For players: - * Removed the experimental `RewriteInParallel` option added in SMAPI 3.6 (it was already disabled by default). Unfortunately this caused intermittent unpredictable errors when enabled. + * Removed the experimental `RewriteInParallel` option added in SMAPI 3.6 (it was already disabled by default). Unfortunately this caused intermittent and unpredictable errors when enabled. * Tweaked the rules for showing update alerts (see _for SMAPI developers_ below for details). * Fixed crossplatform compatibility for mods which use the `[HarmonyPatch(type)]` attribute (thanks to spacechase0!). * Fixed broken URL in update alerts for unofficial versions. * Fixed rare error when a mod adds/removes event handlers asynchronously. + * Fixed rare issue where the console showed incorrect colors when mods wrote to it asynchronously. * For modders: * You can now read/write `SDate` values to JSON (e.g. for `config.json`, network mod messages, etc). @@ -23,6 +24,7 @@ * For SMAPI developers: * The web API now returns an update alert in two new cases: any newer unofficial update (previously only shown if the mod was incompatible), and a newer prerelease version if the installed non-prerelease version is broken (previously only shown if the installed version was prerelease). + * Internal refactoring to simplify future game updates. ## 3.6.2 Released 02 August 2020 for Stardew Valley 1.4.1 or later. diff --git a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs deleted file mode 100644 index ef42e536..00000000 --- a/src/SMAPI/Framework/Logging/ConsoleInterceptionManager.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; - -namespace StardewModdingAPI.Framework.Logging -{ - /// Manages console output interception. - internal class ConsoleInterceptionManager : IDisposable - { - /********* - ** Fields - *********/ - /// The intercepting console writer. - private readonly InterceptingTextWriter Output; - - - /********* - ** Accessors - *********/ - /// The event raised when a message is written to the console directly. - public event Action OnMessageIntercepted; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public ConsoleInterceptionManager() - { - // redirect output through interceptor - this.Output = new InterceptingTextWriter(Console.Out); - this.Output.OnMessageIntercepted += line => this.OnMessageIntercepted?.Invoke(line); - Console.SetOut(this.Output); - } - - /// Get an exclusive lock and write to the console output without interception. - /// The action to perform within the exclusive write block. - public void ExclusiveWriteWithoutInterception(Action action) - { - lock (Console.Out) - { - try - { - this.Output.ShouldIntercept = false; - action(); - } - finally - { - this.Output.ShouldIntercept = true; - } - } - } - - /// Release all resources. - public void Dispose() - { - Console.SetOut(this.Output.Out); - this.Output.Dispose(); - } - } -} diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs index 9ca61b59..d99f1dd2 100644 --- a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs +++ b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs @@ -7,18 +7,22 @@ namespace StardewModdingAPI.Framework.Logging /// A text writer which allows intercepting output. internal class InterceptingTextWriter : TextWriter { + /********* + ** Fields + *********/ + /// Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.) + private readonly char IgnoreChar; + + /********* ** Accessors *********/ /// The underlying console output. public TextWriter Out { get; } - /// The character encoding in which the output is written. + /// public override Encoding Encoding => this.Out.Encoding; - /// Whether to intercept console output. - public bool ShouldIntercept { get; set; } - /// The event raised when a message is written to the console directly. public event Action OnMessageIntercepted; @@ -28,36 +32,53 @@ namespace StardewModdingAPI.Framework.Logging *********/ /// Construct an instance. /// The underlying output writer. - public InterceptingTextWriter(TextWriter output) + /// Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.) + public InterceptingTextWriter(TextWriter output, char ignoreChar) { this.Out = output; + this.IgnoreChar = ignoreChar; } - /// Writes a subarray of characters to the text string or stream. - /// The character array to write data from. - /// The character position in the buffer at which to start retrieving data. - /// The number of characters to write. + /// public override void Write(char[] buffer, int index, int count) { - if (this.ShouldIntercept) - this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); - else + if (buffer.Length == 0) this.Out.Write(buffer, index, count); + else if (buffer[0] == this.IgnoreChar) + this.Out.Write(buffer, index + 1, count - 1); + else if (this.IsEmptyOrNewline(buffer)) + this.Out.Write(buffer, index, count); + else + this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); } - /// Writes a character to the text string or stream. - /// The character to write to the text stream. - /// Console log messages from the game should be caught by . This method passes through anything that bypasses that method for some reason, since it's better to show it to users than hide it from everyone. + /// public override void Write(char ch) { this.Out.Write(ch); } - /// Releases the unmanaged resources used by the and optionally releases the managed resources. - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// protected override void Dispose(bool disposing) { this.OnMessageIntercepted = null; } + + + /********* + ** Private methods + *********/ + /// Get whether a buffer represents a line break. + /// The buffer to check. + private bool IsEmptyOrNewline(char[] buffer) + { + foreach (char ch in buffer) + { + if (ch != '\n' && ch != '\r') + return false; + } + + return true; + } } } diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index 3786e940..d0936f3f 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -21,8 +21,8 @@ namespace StardewModdingAPI.Framework.Logging /// The log file to which to write messages. private readonly LogFileManager LogFile; - /// Manages console output interception. - private readonly ConsoleInterceptionManager InterceptionManager = new ConsoleInterceptionManager(); + /// Prefixing a low-level message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.) + private readonly char IgnoreChar = '\u200B'; /// Get a named monitor instance. private readonly Func GetMonitorImpl; @@ -86,7 +86,7 @@ namespace StardewModdingAPI.Framework.Logging public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode) { // init construction logic - this.GetMonitorImpl = name => new Monitor(name, this.InterceptionManager, this.LogFile, colorConfig, isVerbose) + this.GetMonitorImpl = name => new Monitor(name, this.IgnoreChar, this.LogFile, colorConfig, isVerbose) { WriteToConsole = writeToConsole, ShowTraceInConsole = isDeveloperMode, @@ -99,8 +99,10 @@ namespace StardewModdingAPI.Framework.Logging this.MonitorForGame = this.GetMonitor("game"); // redirect direct console output - if (this.MonitorForGame.WriteToConsole) - this.InterceptionManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + var output = new InterceptingTextWriter(Console.Out, this.IgnoreChar); + if (writeToConsole) + output.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + Console.SetOut(output); } /// Get a monitor instance derived from SMAPI's current settings. @@ -167,7 +169,7 @@ namespace StardewModdingAPI.Framework.Logging public void PressAnyKeyToExit(bool showMessage) { if (showMessage) - Console.WriteLine("Game has ended. Press any key to exit."); + this.Monitor.Log("Game has ended. Press any key to exit."); Thread.Sleep(100); Console.ReadKey(); Environment.Exit(0); @@ -339,7 +341,6 @@ namespace StardewModdingAPI.Framework.Logging /// public void Dispose() { - this.InterceptionManager.Dispose(); this.LogFile.Dispose(); } diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 44eeabe6..527cba64 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -18,8 +18,8 @@ namespace StardewModdingAPI.Framework /// Handles writing text to the console. private readonly IConsoleWriter ConsoleWriter; - /// Manages access to the console output. - private readonly ConsoleInterceptionManager ConsoleInterceptor; + /// Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.) + private readonly char IgnoreChar; /// The log file to which to write messages. private readonly LogFileManager LogFile; @@ -52,11 +52,11 @@ namespace StardewModdingAPI.Framework *********/ /// Construct an instance. /// The name of the module which logs messages using this instance. - /// Intercepts access to the console output. + /// A character which indicates the message should not be intercepted if it appears as the first character of a string written to the console. The character itself is not logged in that case. /// The log file to which to write messages. /// The colors to use for text written to the SMAPI console. /// Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed. - public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose) + public Monitor(string source, char ignoreChar, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework this.Source = source; this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig); - this.ConsoleInterceptor = consoleInterceptor; + this.IgnoreChar = ignoreChar; this.IsVerbose = isVerbose; } @@ -99,7 +99,7 @@ namespace StardewModdingAPI.Framework internal void Newline() { if (this.WriteToConsole) - this.ConsoleInterceptor.ExclusiveWriteWithoutInterception(Console.WriteLine); + Console.WriteLine(); this.LogFile.WriteLine(""); } @@ -136,12 +136,7 @@ namespace StardewModdingAPI.Framework // write to console if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace)) - { - this.ConsoleInterceptor.ExclusiveWriteWithoutInterception(() => - { - this.ConsoleWriter.WriteLine(consoleMessage, level); - }); - } + this.ConsoleWriter.WriteLine(this.IgnoreChar + consoleMessage, level); // write to log file this.LogFile.WriteLine(fullMessage); -- cgit From 4088f4cb2bfe777cf6f86ac5fbf64f7d67565057 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 4 Sep 2020 22:02:59 -0400 Subject: simplify error shown for duplicate mods --- docs/release-notes.md | 3 +- src/SMAPI.Tests/Core/ModResolverTests.cs | 22 ++++++------ src/SMAPI/Framework/IModMetadata.cs | 10 +++++- src/SMAPI/Framework/Logging/LogManager.cs | 13 +++++++ src/SMAPI/Framework/ModLoading/ModFailReason.cs | 27 ++++++++++++++ src/SMAPI/Framework/ModLoading/ModMetadata.cs | 14 +++++++- src/SMAPI/Framework/ModLoading/ModResolver.cs | 48 ++++++++++++------------- src/SMAPI/Framework/SCore.cs | 21 ++++++++--- 8 files changed, 116 insertions(+), 42 deletions(-) create mode 100644 src/SMAPI/Framework/ModLoading/ModFailReason.cs (limited to 'src/SMAPI/Framework/Logging') diff --git a/docs/release-notes.md b/docs/release-notes.md index ae636153..297d8394 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,8 +9,9 @@ ## Upcoming release * For players: - * Added heuristic compatibility rewrites. (This fixes some mods previously broken on Android, and improves compatibility with future game updates.) + * Added heuristic compatibility rewrites. (This improves mod compatibility with Android and future game updates.) * Tweaked the rules for showing update alerts (see _for SMAPI developers_ below for details). + * Simplified error shown for duplicate mods. * Fixed crossplatform compatibility for mods which use the `[HarmonyPatch(type)]` attribute (thanks to spacechase0!). * Fixed map tile rotation broken when you return to the title screen and reload a save. * Fixed broken URL in update alerts for unofficial versions. diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 4f3a12cb..78056ef7 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -154,7 +154,7 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); } [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] @@ -169,7 +169,7 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] @@ -183,7 +183,7 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the metadata."); } [Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")] @@ -200,8 +200,8 @@ namespace SMAPI.Tests.Core new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); // assert - modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the first mod with a unique ID."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the second mod with a unique ID."); + modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the first mod with a unique ID."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "The validation did not fail the second mod with a unique ID."); } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] @@ -367,9 +367,9 @@ namespace SMAPI.Tests.Core Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); - modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); - modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); + modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); + modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); + modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); } [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] @@ -408,7 +408,7 @@ namespace SMAPI.Tests.Core // assert Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); } [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] @@ -525,8 +525,8 @@ namespace SMAPI.Tests.Core if (allowStatusChange) { mod - .Setup(p => p.SetStatus(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((status, message, errorDetails) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}\n{errorDetails}")) + .Setup(p => p.SetStatus(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((status, failReason, message, errorDetails) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}\n{failReason}\n{errorDetails}")) .Returns(mod.Object); } return mod; diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 6a635b76..70cf0036 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -31,6 +31,9 @@ namespace StardewModdingAPI.Framework /// The metadata resolution status. ModMetadataStatus Status { get; } + /// The reason the mod failed to load, if applicable. + ModFailReason? FailReason { get; } + /// Indicates non-error issues with the mod. ModWarning Warnings { get; } @@ -65,12 +68,17 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ + /// Set the mod status to . + /// Return the instance for chaining. + IModMetadata SetStatusFound(); + /// Set the mod status. /// The metadata resolution status. + /// The reason a mod could not be loaded. /// The reason the metadata is invalid, if any. /// A detailed technical message, if any. /// Return the instance for chaining. - IModMetadata SetStatus(ModMetadataStatus status, string error = null, string errorDetails = null); + IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null); /// Set a warning flag for the mod. /// The warning to set. diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index d0936f3f..094dd749 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading; using StardewModdingAPI.Framework.Commands; +using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Internal.ConsoleWriting; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Utilities; @@ -397,10 +398,22 @@ namespace StardewModdingAPI.Framework.Logging if (skippedMods.Any()) { // get logging logic + HashSet loggedDuplicateIds = new HashSet(); void LogSkippedMod(IModMetadata mod) { string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {mod.Error}"; + // handle duplicate mods + // (log first duplicate only, don't show redundant version) + if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest()) + { + if (!loggedDuplicateIds.Add(mod.Manifest.UniqueID)) + return; // already logged + + message = $" - {mod.DisplayName} because {mod.Error}"; + } + + // log message this.Monitor.Log(message, LogLevel.Error); if (mod.ErrorDetails != null) this.Monitor.Log($" ({mod.ErrorDetails})"); diff --git a/src/SMAPI/Framework/ModLoading/ModFailReason.cs b/src/SMAPI/Framework/ModLoading/ModFailReason.cs new file mode 100644 index 00000000..cd4623e7 --- /dev/null +++ b/src/SMAPI/Framework/ModLoading/ModFailReason.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// Indicates why a mod could not be loaded. + internal enum ModFailReason + { + /// The mod has been disabled by prefixing its folder with a dot. + DisabledByDotConvention, + + /// Multiple copies of the mod are installed. + Duplicate, + + /// The mod has incompatible code instructions, needs a newer SMAPI version, or is marked 'assume broken' in the SMAPI metadata list. + Incompatible, + + /// The mod's manifest is missing or invalid. + InvalidManifest, + + /// The mod was deemed compatible, but SMAPI failed when it tried to load it. + LoadFailed, + + /// The mod requires other mods which aren't installed, or its dependencies have a circular reference. + MissingDependencies, + + /// The mod is marked obsolete in the SMAPI metadata list. + Obsolete + } +} diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index e793b0cd..18d2b112 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// public ModMetadataStatus Status { get; private set; } + /// + public ModFailReason? FailReason { get; private set; } + /// public ModWarning Warnings { get; private set; } @@ -93,9 +96,18 @@ namespace StardewModdingAPI.Framework.ModLoading } /// - public IModMetadata SetStatus(ModMetadataStatus status, string error = null, string errorDetails = null) + public IModMetadata SetStatusFound() + { + this.SetStatus(ModMetadataStatus.Found, ModFailReason.Incompatible, null); + this.FailReason = null; + return this; + } + + /// + public IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null) { this.Status = status; + this.FailReason = reason; this.Error = error; this.ErrorDetails = errorDetails; return this; diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 8bbeb2a3..08df7b76 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -43,8 +43,13 @@ namespace StardewModdingAPI.Framework.ModLoading ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore) - .SetStatus(status, shouldIgnore ? "disabled by dot convention" : folder.ManifestParseErrorText); + var metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore); + if (shouldIgnore) + metadata.SetStatus(status, ModFailReason.DisabledByDotConvention, "disabled by dot convention"); + else + metadata.SetStatus(status, ModFailReason.InvalidManifest, folder.ManifestParseErrorText); + + yield return metadata; } } @@ -67,7 +72,7 @@ namespace StardewModdingAPI.Framework.ModLoading switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: - mod.SetStatus(ModMetadataStatus.Failed, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}"); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Obsolete, $"it's obsolete: {mod.DataRecord.StatusReasonPhrase}"); continue; case ModStatus.AssumeBroken: @@ -97,7 +102,7 @@ namespace StardewModdingAPI.Framework.ModLoading error += $"version newer than {mod.DataRecord.StatusUpperVersion}"; error += " at " + string.Join(" or ", updateUrls); - mod.SetStatus(ModMetadataStatus.Failed, error); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, error); } continue; } @@ -105,7 +110,7 @@ namespace StardewModdingAPI.Framework.ModLoading // validate SMAPI version if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) { - mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Incompatible, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); continue; } @@ -117,12 +122,12 @@ namespace StardewModdingAPI.Framework.ModLoading // validate field presence if (!hasDll && !isContentPack) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); + 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, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); continue; } @@ -132,14 +137,14 @@ namespace StardewModdingAPI.Framework.ModLoading // invalid filename format if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); continue; } // invalid path if (!File.Exists(Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll))) { - mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); continue; } @@ -147,7 +152,7 @@ namespace StardewModdingAPI.Framework.ModLoading string actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll).FirstOrDefault()?.Name; if (actualFilename != mod.Manifest.EntryDll) { - mod.SetStatus(ModMetadataStatus.Failed, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); continue; } } @@ -158,7 +163,7 @@ namespace StardewModdingAPI.Framework.ModLoading // invalid content pack ID if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID)) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); continue; } } @@ -177,14 +182,14 @@ namespace StardewModdingAPI.Framework.ModLoading if (missingFields.Any()) { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); continue; } } // validate ID format if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) - mod.SetStatus(ModMetadataStatus.Failed, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); } // validate IDs are unique @@ -199,13 +204,8 @@ namespace StardewModdingAPI.Framework.ModLoading if (mod.Status == ModMetadataStatus.Failed) continue; // don't replace metadata error - string folderList = string.Join(", ", - from entry in @group - let relativePath = entry.GetRelativePathWithRoot() - orderby relativePath - select $"{relativePath} ({entry.Manifest.Version})" - ); - mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed. Found in folders: {folderList}."); + string folderList = string.Join(", ", group.Select(p => p.GetRelativePathWithRoot()).OrderBy(p => p)); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, $"you have multiple copies of this mod installed. To fix this, delete these folders and reinstall the mod: {folderList}."); } } } @@ -298,7 +298,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (failedModNames.Any()) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it requires mods which aren't installed ({string.Join(", ", failedModNames)})."); return states[mod] = ModDependencyStatus.Failed; } } @@ -315,7 +315,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (failedLabels.Any()) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); return states[mod] = ModDependencyStatus.Failed; } } @@ -338,7 +338,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (states[requiredMod] == ModDependencyStatus.Checking) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); return states[mod] = ModDependencyStatus.Failed; } @@ -354,7 +354,7 @@ namespace StardewModdingAPI.Framework.ModLoading // failed, which means this mod can't be loaded either case ModDependencyStatus.Failed: sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.MissingDependencies, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); return states[mod] = ModDependencyStatus.Failed; // unexpected status diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index bfe6e277..52b4b9cf 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1339,9 +1339,10 @@ namespace StardewModdingAPI.Framework // load mods foreach (IModMetadata mod in mods) { - if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails)) + if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out ModFailReason? failReason, out string errorPhrase, out string errorDetails)) { - mod.SetStatus(ModMetadataStatus.Failed, errorPhrase, errorDetails); + failReason ??= ModFailReason.LoadFailed; + mod.SetStatus(ModMetadataStatus.Failed, failReason.Value, errorPhrase, errorDetails); skippedMods.Add(mod); } } @@ -1437,16 +1438,17 @@ namespace StardewModdingAPI.Framework /// Load a given mod. /// The mod to load. /// The mods being loaded. - /// Preprocesses and loads mod assemblies + /// Preprocesses and loads mod assemblies. /// Generates proxy classes to access mod APIs through an arbitrary interface. /// The JSON helper with which to read mods' JSON files. /// The content manager to use for mod content. /// Handles access to SMAPI's internal mod metadata list. /// The mod IDs to ignore when validating update keys. + /// The reason the mod couldn't be loaded, if applicable. /// The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable). /// More detailed details about the error intended for developers (if any). /// Returns whether the mod was successfully loaded. - private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet suppressUpdateChecks, out string errorReasonPhrase, out string errorDetails) + private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet suppressUpdateChecks, out ModFailReason? failReason, out string errorReasonPhrase, out string errorDetails) { errorDetails = null; @@ -1469,6 +1471,7 @@ namespace StardewModdingAPI.Framework if (mod.Status == ModMetadataStatus.Failed) { this.Monitor.Log($" Failed: {mod.Error}"); + failReason = mod.FailReason; errorReasonPhrase = mod.Error; return false; } @@ -1485,6 +1488,7 @@ namespace StardewModdingAPI.Framework .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) ?.DisplayName ?? dependency.UniqueID; errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; + failReason = ModFailReason.MissingDependencies; return false; } } @@ -1502,6 +1506,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry.Add(mod); errorReasonPhrase = null; + failReason = null; return true; } @@ -1524,17 +1529,20 @@ namespace StardewModdingAPI.Framework { string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray(); errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}"; + failReason = ModFailReason.Incompatible; return false; } catch (SAssemblyLoadFailedException ex) { errorReasonPhrase = $"its DLL couldn't be loaded: {ex.Message}"; + failReason = ModFailReason.LoadFailed; return false; } catch (Exception ex) { errorReasonPhrase = "its DLL couldn't be loaded."; errorDetails = $"Error: {ex.GetLogSummary()}"; + failReason = ModFailReason.LoadFailed; return false; } @@ -1543,7 +1551,10 @@ namespace StardewModdingAPI.Framework { // get mod instance if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase)) + { + failReason = ModFailReason.LoadFailed; return false; + } // get content packs IContentPack[] GetContentPacks() @@ -1591,11 +1602,13 @@ namespace StardewModdingAPI.Framework // track mod mod.SetMod(modEntry, translationHelper); this.ModRegistry.Add(mod); + failReason = null; return true; } catch (Exception ex) { errorReasonPhrase = $"initialization failed:\n{ex.GetLogSummary()}"; + failReason = ModFailReason.LoadFailed; return false; } } -- cgit