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.Tests/Core/ModResolverTests.cs | 22 +- src/SMAPI.sln.DotSettings | 2 + src/SMAPI/Framework/IModMetadata.cs | 6 +- src/SMAPI/Framework/Logging/LogManager.cs | 586 +++++++++++++++++++++++++ src/SMAPI/Framework/ModLoading/ModMetadata.cs | 83 ++-- src/SMAPI/Framework/SCore.cs | 590 +++----------------------- 6 files changed, 705 insertions(+), 584 deletions(-) create mode 100644 src/SMAPI/Framework/Logging/LogManager.cs (limited to 'src') diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 45b3673b..4f3a12cb 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()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, 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()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, 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()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, 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()), Times.Once, "The validation did not fail the first mod with a unique ID."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, 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()), 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."); } [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()), 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()), 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()), 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()), 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."); } [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()), 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()), 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())) - .Callback((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) + .Setup(p => p.SetStatus(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((status, message, errorDetails) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}\n{errorDetails}")) .Returns(mod.Object); } return mod; diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 05caa938..76e863cc 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -3,6 +3,7 @@ DO_NOT_SHOW HINT HINT + DO_NOT_SHOW Field, Property, Event, Method Field, Property, Event, Method True @@ -53,6 +54,7 @@ True True True + True True True True diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 1231b494..6a635b76 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework /// The reason the metadata is invalid, if any. string Error { get; } + /// A detailed technical message for , if any. + public string ErrorDetails { get; } + /// Whether the mod folder should be ignored. This is true if it was found within a folder whose name starts with a dot. bool IsIgnored { get; } @@ -65,8 +68,9 @@ namespace StardewModdingAPI.Framework /// Set the mod status. /// The metadata resolution status. /// 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); + IModMetadata SetStatus(ModMetadataStatus status, string error = null, 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 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; + } + } + } +} diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 3ad1bd38..e793b0cd 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -16,55 +16,58 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Accessors *********/ - /// The mod's display name. + /// public string DisplayName { get; } - /// The root path containing mods. + /// public string RootPath { get; } - /// The mod's full directory path within the . + /// public string DirectoryPath { get; } - /// The relative to the . + /// public string RelativeDirectoryPath { get; } - /// The mod manifest. + /// public IManifest Manifest { get; } - /// Metadata about the mod from SMAPI's internal data (if any). + /// public ModDataRecordVersionedFields DataRecord { get; } - /// The metadata resolution status. + /// public ModMetadataStatus Status { get; private set; } - /// Indicates non-error issues with the mod. + /// public ModWarning Warnings { get; private set; } - /// The reason the metadata is invalid, if any. + /// public string Error { get; private set; } - /// Whether the mod folder should be ignored. This is true if it was found within a folder whose name starts with a dot. + /// + public string ErrorDetails { get; private set; } + + /// public bool IsIgnored { get; } - /// The mod instance (if loaded and is false). + /// public IMod Mod { get; private set; } - /// The content pack instance (if loaded and is true). + /// public IContentPack ContentPack { get; private set; } - /// The translations for this mod (if loaded). + /// public TranslationHelper Translations { get; private set; } - /// Writes messages to the console and log file as this mod. + /// public IMonitor Monitor { get; private set; } - /// The mod-provided API (if any). + /// public object Api { get; private set; } - /// The update-check metadata for this mod (if any). + /// public ModEntryModel UpdateCheckData { get; private set; } - /// Whether the mod is a content pack. + /// public bool IsContentPack => this.Manifest?.ContentPackFor != null; @@ -89,28 +92,23 @@ namespace StardewModdingAPI.Framework.ModLoading this.IsIgnored = isIgnored; } - /// Set the mod status. - /// The metadata resolution status. - /// The reason the metadata is invalid, if any. - /// Return the instance for chaining. - public IModMetadata SetStatus(ModMetadataStatus status, string error = null) + /// + public IModMetadata SetStatus(ModMetadataStatus status, string error = null, string errorDetails = null) { this.Status = status; this.Error = error; + this.ErrorDetails = errorDetails; return this; } - /// Set a warning flag for the mod. - /// The warning to set. + /// public IModMetadata SetWarning(ModWarning warning) { this.Warnings |= warning; return this; } - /// Set the mod instance. - /// The mod instance to set. - /// The translations for this mod (if loaded). + /// public IModMetadata SetMod(IMod mod, TranslationHelper translations) { if (this.ContentPack != null) @@ -122,10 +120,7 @@ namespace StardewModdingAPI.Framework.ModLoading return this; } - /// Set the mod instance. - /// The contentPack instance to set. - /// Writes messages to the console and log file. - /// The translations for this mod (if loaded). + /// public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations) { if (this.Mod != null) @@ -137,29 +132,27 @@ namespace StardewModdingAPI.Framework.ModLoading return this; } - /// Set the mod-provided API instance. - /// The mod-provided API. + /// public IModMetadata SetApi(object api) { this.Api = api; return this; } - /// Set the update-check metadata for this mod. - /// The update-check metadata. + /// public IModMetadata SetUpdateData(ModEntryModel data) { this.UpdateCheckData = data; return this; } - /// Whether the mod manifest was loaded (regardless of whether the mod itself was loaded). + /// public bool HasManifest() { return this.Manifest != null; } - /// Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded). + /// public bool HasID() { return @@ -167,8 +160,7 @@ namespace StardewModdingAPI.Framework.ModLoading && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID); } - /// Whether the mod has the given ID. - /// The mod ID to check. + /// public bool HasID(string id) { return @@ -176,8 +168,7 @@ namespace StardewModdingAPI.Framework.ModLoading && string.Equals(this.Manifest.UniqueID.Trim(), id?.Trim(), StringComparison.OrdinalIgnoreCase); } - /// Get the defined update keys. - /// Only return valid update keys. + /// public IEnumerable GetUpdateKeys(bool validOnly = false) { foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0]) @@ -188,8 +179,7 @@ namespace StardewModdingAPI.Framework.ModLoading } } - /// Get the mod IDs that must be installed to load this mod. - /// Whether to include optional dependencies. + /// public IEnumerable GetRequiredModIds(bool includeOptional = false) { HashSet required = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -209,14 +199,13 @@ namespace StardewModdingAPI.Framework.ModLoading yield return this.Manifest.ContentPackFor.UniqueID; } - /// Whether the mod has at least one valid update key set. + /// public bool HasValidUpdateKeys() { return this.GetUpdateKeys(validOnly: true).Any(); } - /// Get whether the mod has any of the given warnings which haven't been suppressed in the . - /// The warnings to check. + /// public bool HasUnsuppressedWarnings(params ModWarning[] warnings) { return warnings.Any(warning => @@ -225,7 +214,7 @@ namespace StardewModdingAPI.Framework.ModLoading ); } - /// Get a relative path which includes the root folder name. + /// public string GetRelativePathWithRoot() { string rootFolderName = Path.GetFileName(this.RootPath) ?? ""; diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index fd8d7034..5bd02dc8 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; @@ -9,14 +8,12 @@ using System.Reflection; using System.Runtime.ExceptionServices; using System.Security; using System.Text; -using System.Text.RegularExpressions; using System.Threading; #if SMAPI_FOR_WINDOWS using System.Windows.Forms; #endif using Newtonsoft.Json; using StardewModdingAPI.Events; -using StardewModdingAPI.Framework.Commands; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Logging; @@ -36,7 +33,6 @@ using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Utilities; using StardewValley; using Object = StardewValley.Object; -using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI.Framework { @@ -46,17 +42,11 @@ namespace StardewModdingAPI.Framework /********* ** Fields *********/ - /// The log file to which to write messages. - private readonly LogFileManager LogFile; - - /// Manages console output interception. - private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); + /// Manages the SMAPI console window and log file. + private readonly LogManager LogManager; /// The core logger and monitor for SMAPI. - private readonly Monitor Monitor; - - /// The core logger and monitor on behalf of the game. - private readonly Monitor MonitorForGame; + private Monitor Monitor => this.LogManager.Monitor; /// Tracks whether the game should exit immediately and any pending initialization should be cancelled. private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); @@ -89,39 +79,6 @@ namespace StardewModdingAPI.Framework /// Whether the program has been disposed. private bool IsDisposed; - /// 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 - ) - }; - /// The mod toolkit used for generic mod interactions. private readonly ModToolkit Toolkit = new ModToolkit(); @@ -163,14 +120,7 @@ namespace StardewModdingAPI.Framework if (File.Exists(Constants.ApiUserConfigPath)) JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); - this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) - { - WriteToConsole = writeToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - this.MonitorForGame = this.GetSecondaryMonitor("game"); + this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode); SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor); this.EventManager = new EventManager(this.ModRegistry, SCore.PerformanceMonitor); @@ -180,38 +130,21 @@ namespace StardewModdingAPI.Framework SDate.Translations = this.Translator; - // redirect direct console output - if (this.MonitorForGame.WriteToConsole) - this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); - - // 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.)", LogLevel.Trace); - this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); - - // log custom settings - { - IDictionary customSettings = this.Settings.GetCustomSettings(); - if (customSettings.Any()) - this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}", LogLevel.Trace); - } + // log SMAPI/OS info + this.LogManager.LogIntro(modsPath, this.Settings.GetCustomSettings()); // validate platform #if SMAPI_FOR_WINDOWS if (Constants.Platform != Platform.Windows) { this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or Mac. Please reinstall SMAPI to fix this.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; + this.LogManager.PressAnyKeyToExit(); } #else if (Constants.Platform == Platform.Windows) { this.Monitor.Log($"Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error); this.PressAnyKeyToExit(); - return; } #endif } @@ -249,7 +182,7 @@ namespace StardewModdingAPI.Framework SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded); this.GameInstance = new SGame( monitor: this.Monitor, - monitorForGame: this.MonitorForGame, + monitorForGame: this.LogManager.MonitorForGame, reflection: this.Reflection, translator: this.Translator, eventManager: this.EventManager, @@ -267,12 +200,12 @@ namespace StardewModdingAPI.Framework // apply game patches new GamePatcher(this.Monitor).Apply( - new EventErrorPatch(this.MonitorForGame), - new DialogueErrorPatch(this.MonitorForGame, this.Reflection), + new EventErrorPatch(this.LogManager.MonitorForGame), + new DialogueErrorPatch(this.LogManager.MonitorForGame, this.Reflection), new ObjectErrorPatch(), new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged), new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved), - new ScheduleErrorPatch(this.MonitorForGame) + new ScheduleErrorPatch(this.LogManager.MonitorForGame) ); // add exit handler @@ -281,71 +214,29 @@ namespace StardewModdingAPI.Framework this.CancellationToken.Token.WaitHandle.WaitOne(); if (this.IsGameRunning) { - 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); - } - + this.LogManager.WriteCrashLog(); this.GameInstance.Exit(); } }).Start(); // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + this.LogManager.SetConsoleTitle($"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"); } catch (Exception ex) { this.Monitor.Log($"SMAPI failed to initialize: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); + this.LogManager.PressAnyKeyToExit(); return; } - // check update marker - 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); - } + // log basic info + this.LogManager.HandleMarkerFiles(); + this.LogManager.LogSettingsHeader(this.Settings.DeveloperMode, this.Settings.CheckForUpdates); - // 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); - } - - // add headers - if (this.Settings.DeveloperMode) - 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 (!this.Settings.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."); - - // update window titles + // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + this.LogManager.SetConsoleTitle($"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"); // start game this.Monitor.Log("Starting game...", LogLevel.Debug); @@ -355,22 +246,10 @@ namespace StardewModdingAPI.Framework StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window this.GameInstance.Run(); } - catch (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()}", LogLevel.Trace); - this.PressAnyKeyToExit(); - } - catch (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()}", LogLevel.Trace); - this.PressAnyKeyToExit(); - } catch (Exception ex) { - this.MonitorForGame.Log($"The game failed to launch: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); + this.LogManager.LogFatalLaunchError(ex); + this.LogManager.PressAnyKeyToExit(); } finally { @@ -385,7 +264,7 @@ namespace StardewModdingAPI.Framework if (this.IsDisposed) return; this.IsDisposed = true; - this.Monitor.Log("Disposing...", LogLevel.Trace); + this.Monitor.Log("Disposing..."); // dispose mod data foreach (IModMetadata mod in this.ModRegistry.GetAll()) @@ -402,11 +281,10 @@ namespace StardewModdingAPI.Framework // dispose core components this.IsGameRunning = false; - this.ConsoleManager?.Dispose(); + this.LogManager?.Dispose(); this.ContentCore?.Dispose(); this.CancellationToken?.Dispose(); this.GameInstance?.Dispose(); - this.LogFile?.Dispose(); // end game (moved from Game1.OnExiting to let us clean up first) Process.GetCurrentProcess().Kill(); @@ -426,15 +304,7 @@ namespace StardewModdingAPI.Framework } // init TMX support - try - { - xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom)); - } - catch (Exception ex) - { - this.Monitor.Log("SMAPI couldn't load TMX support. Some mods may not work correctly.", LogLevel.Warn); - this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); - } + xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom)); // load mod data ModToolkit toolkit = new ModToolkit(); @@ -442,14 +312,14 @@ namespace StardewModdingAPI.Framework // load mods { - this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); + this.Monitor.Log("Loading mod metadata..."); ModResolver resolver = new ModResolver(); // log loose files { string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray(); if (looseFiles.Any()) - this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}", LogLevel.Trace); + this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}"); } // load manifests @@ -457,7 +327,7 @@ namespace StardewModdingAPI.Framework // filter out ignored mods foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) - this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).", LogLevel.Trace); + this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot)."); mods = mods.Where(p => !p.IsIgnored).ToArray(); // load mods @@ -472,7 +342,7 @@ namespace StardewModdingAPI.Framework // update window titles int modsLoaded = this.ModRegistry.GetAll().Count(); this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; + this.LogManager.SetConsoleTitle($"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"); } /// Initialize SMAPI and mods after the game starts. @@ -483,7 +353,14 @@ 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.RunConsoleLoop).Start(); + new Thread( + () => this.LogManager.RunConsoleInputLoop( + commandManager: this.GameInstance.CommandManager, + reloadTranslations: this.ReloadTranslations, + handleInput: input => this.GameInstance.CommandQueue.Enqueue(input), + continueWhile: () => this.IsGameRunning && !this.CancellationToken.IsCancellationRequested + ) + ).Start(); } /// Handle the game changing locale. @@ -503,53 +380,18 @@ namespace StardewModdingAPI.Framework mod.Translations.SetLocale(locale, languageCode); } - /// Run a loop handling console input. - [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] - private void RunConsoleLoop() - { - // prepare console - this.Monitor.Log("Type 'help' for help, or 'help ' for a command's usage", LogLevel.Info); - this.GameInstance.CommandManager - .Add(new HelpCommand(this.GameInstance.CommandManager), this.Monitor) - .Add(new HarmonySummaryCommand(), this.Monitor) - .Add(new ReloadI18nCommand(this.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); - this.GameInstance.CommandQueue.Enqueue(input); - } - }); - inputThread.Start(); - - // keep console thread alive while the game is running - while (this.IsGameRunning && !this.CancellationToken.IsCancellationRequested) - Thread.Sleep(1000 / 10); - if (inputThread.ThreadState == ThreadState.Running) - inputThread.Abort(); - } - /// Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated. /// Returns whether all integrity checks passed. private bool ValidateContentIntegrity() { - this.Monitor.Log("Detecting common issues...", LogLevel.Trace); + this.Monitor.Log("Detecting common issues..."); bool issuesFound = false; // object format (commonly broken by outdated files) { // detect issues bool hasObjectIssues = false; - void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); + void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue})."); foreach (KeyValuePair entry in Game1.objectInformation) { // must not be empty @@ -608,7 +450,7 @@ namespace StardewModdingAPI.Framework url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac #endif WebApiClient client = new WebApiClient(url, Constants.ApiVersion); - this.Monitor.Log("Checking for updates...", LogLevel.Trace); + this.Monitor.Log("Checking for updates..."); // check SMAPI version ISemanticVersion updateFound = null; @@ -619,7 +461,7 @@ namespace StardewModdingAPI.Framework if (response.SuggestedUpdate != null) this.Monitor.Log($"You can update SMAPI to {response.SuggestedUpdate.Version}: {Constants.HomePageUrl}", LogLevel.Alert); else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); + this.Monitor.Log(" SMAPI okay."); updateFound = response.SuggestedUpdate?.Version; @@ -627,7 +469,7 @@ namespace StardewModdingAPI.Framework if (response.Errors.Any()) { this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}", LogLevel.Trace); + this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); } } catch (Exception ex) @@ -635,13 +477,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); this.Monitor.Log(ex is WebException && ex.InnerException == null ? $"Error: {ex.Message}" - : $"Error: {ex.GetLogSummary()}", LogLevel.Trace + : $"Error: {ex.GetLogSummary()}" ); } // show update message on next launch if (updateFound != null) - File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); + this.LogManager.WriteUpdateMarker(updateFound.ToString()); // check mod versions if (mods.Any()) @@ -665,7 +507,7 @@ namespace StardewModdingAPI.Framework } // fetch results - this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); + this.Monitor.Log($" Checking for updates to {searchMods.Count} mods..."); IDictionary results = client.GetModInfo(searchMods.ToArray(), apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform); // extract update alerts & errors @@ -694,7 +536,7 @@ namespace StardewModdingAPI.Framework // show update errors if (errors.Length != 0) - this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); + this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd()); // show update alerts if (updates.Any()) @@ -710,14 +552,14 @@ namespace StardewModdingAPI.Framework } } else - this.Monitor.Log(" All mods up to date.", LogLevel.Trace); + this.Monitor.Log(" All mods up to date."); } catch (Exception ex) { this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); this.Monitor.Log(ex is WebException && ex.InnerException == null ? ex.Message - : ex.ToString(), LogLevel.Trace + : ex.ToString() ); } } @@ -747,27 +589,24 @@ namespace StardewModdingAPI.Framework /// Handles access to SMAPI's internal mod metadata list. private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) { - this.Monitor.Log("Loading mods...", LogLevel.Trace); + this.Monitor.Log("Loading mods..."); // load mods - IDictionary> skippedMods = new Dictionary>(); + IList skippedMods = new List(); using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings)) { // init HashSet suppressUpdateChecks = new HashSet(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase); InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - void LogSkip(IModMetadata mod, string errorPhrase, string errorDetails) - { - skippedMods[mod] = Tuple.Create(errorPhrase, errorDetails); - if (mod.Status != ModMetadataStatus.Failed) - mod.SetStatus(ModMetadataStatus.Failed, errorPhrase); - } // load mods - foreach (IModMetadata contentPack in mods) + foreach (IModMetadata mod in mods) { - if (!this.TryLoadMod(contentPack, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails)) - LogSkip(contentPack, errorPhrase, errorDetails); + if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails)) + { + mod.SetStatus(ModMetadataStatus.Failed, errorPhrase, errorDetails); + skippedMods.Add(mod); + } } } IModMetadata[] loaded = this.ModRegistry.GetAll().ToArray(); @@ -777,42 +616,8 @@ namespace StardewModdingAPI.Framework // unlock content packs this.ModRegistry.AreAllModsLoaded = true; - // 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); + // log mod info + this.LogManager.LogModInfo(loaded, loadedContentPacks, loadedMods, skippedMods.ToArray(), this.Settings.ParanoidWarnings); // initialize translations this.ReloadTranslations(loaded); @@ -856,7 +661,7 @@ namespace StardewModdingAPI.Framework } if (api != null) - this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); + this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName})."); metadata.SetApi(api); } catch (Exception ex) @@ -918,11 +723,11 @@ namespace StardewModdingAPI.Framework { string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) - this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]..."); else if (mod.Manifest?.EntryDll != null) - this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})..."); // don't use Path.Combine here, since EntryDLL might not be valid else - this.Monitor.Log($" {mod.DisplayName} (from {relativePath})...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath})..."); } // add warning for missing update key @@ -932,7 +737,7 @@ namespace StardewModdingAPI.Framework // validate status if (mod.Status == ModMetadataStatus.Failed) { - this.Monitor.Log($" Failed: {mod.Error}", LogLevel.Trace); + this.Monitor.Log($" Failed: {mod.Error}"); errorReasonPhrase = mod.Error; return false; } @@ -958,7 +763,7 @@ namespace StardewModdingAPI.Framework if (mod.IsContentPack) { IManifest manifest = mod.Manifest; - IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper); @@ -1023,13 +828,13 @@ namespace StardewModdingAPI.Framework } // init mod helpers - IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IMonitor packMonitor = this.LogManager.GetMonitor(packManifest.Name); IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); @@ -1065,182 +870,6 @@ namespace StardewModdingAPI.Framework } } - /// Write a summary of mod warnings to the console and log. - /// The loaded mods. - /// The mods which were skipped, along with the friendly and developer reasons. - private void LogModWarnings(IEnumerable mods, IDictionary> skippedMods) - { - // 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.Union(skippedMods.Keys).Count(); - this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); - } - - // log skipped mods - if (skippedMods.Any()) - { - // get logging logic - HashSet logged = new HashSet(); - void LogSkippedMod(IModMetadata mod, string errorReason, string errorDetails) - { - string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}"; - - if (logged.Add($"{message}|{errorDetails}")) - { - this.Monitor.Log(message, LogLevel.Error); - if (errorDetails != null) - this.Monitor.Log($" ({errorDetails})", LogLevel.Trace); - } - } - - // find skipped dependencies - KeyValuePair>[] skippedDependencies; - { - HashSet skippedDependencyIds = new HashSet(StringComparer.OrdinalIgnoreCase); - HashSet skippedModIds = new HashSet(from mod in skippedMods where mod.Key.HasID() select mod.Key.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); - foreach (IModMetadata mod in skippedMods.Keys) - { - foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) - skippedDependencyIds.Add(requiredId); - } - skippedDependencies = skippedMods.Where(p => p.Key.HasID() && skippedDependencyIds.Contains(p.Key.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 (var pair in skippedDependencies.OrderBy(p => p.Key.DisplayName)) - LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2); - this.Monitor.Newline(); - } - - foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) - LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2); - 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 (this.Settings.ParanoidWarnings) - { - 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. - void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) - { - this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); - } - /// Load a mod's entry class. /// The mod assembly. /// The loaded instance. @@ -1366,64 +995,6 @@ namespace StardewModdingAPI.Framework return translations; } - /// 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, LogLevel.Trace); - return; - } - } - - // forward to monitor - gameMonitor.Log(message, level); - } - - /// Show a 'press any key to exit' message, and exit when they press a key. - private 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. - private void PressAnyKeyToExit(bool showMessage) - { - if (showMessage) - Console.WriteLine("Game has ended. Press any key to exit."); - Thread.Sleep(100); - Console.ReadKey(); - Environment.Exit(0); - } - - /// Get a monitor instance derived from SMAPI's current settings. - /// The name of the module which will log messages with this instance. - private Monitor GetSecondaryMonitor(string name) - { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) - { - WriteToConsole = this.Monitor.WriteToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - } - /// Get the absolute path to the next available log file. private string GetLogPath() { @@ -1474,36 +1045,5 @@ namespace StardewModdingAPI.Framework } } } - - /// 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