diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-08-23 14:22:27 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-08-23 14:22:27 -0400 |
commit | ad1b9a870b5383ca9ada8c52b2bd76960d5579da (patch) | |
tree | 9ac011aad31d753336ded58fff72407969d92d27 /src | |
parent | cb37644291012a8f4797e009bf0fdcb3ab51845f (diff) | |
download | SMAPI-ad1b9a870b5383ca9ada8c52b2bd76960d5579da.tar.gz SMAPI-ad1b9a870b5383ca9ada8c52b2bd76960d5579da.tar.bz2 SMAPI-ad1b9a870b5383ca9ada8c52b2bd76960d5579da.zip |
move some console/logging logic out of SCore into a new LogManager
Diffstat (limited to 'src')
-rw-r--r-- | src/SMAPI.Tests/Core/ModResolverTests.cs | 22 | ||||
-rw-r--r-- | src/SMAPI.sln.DotSettings | 2 | ||||
-rw-r--r-- | src/SMAPI/Framework/IModMetadata.cs | 6 | ||||
-rw-r--r-- | src/SMAPI/Framework/Logging/LogManager.cs | 586 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModLoading/ModMetadata.cs | 83 | ||||
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 590 |
6 files changed, 705 insertions, 584 deletions
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<string>()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>(), It.IsAny<string>()), 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<string>()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>(), It.IsAny<string>()), 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<string>()), Times.Once, "The validation did not fail the metadata."); + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>(), It.IsAny<string>()), 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<string>()), Times.Once, "The validation did not fail the first mod with a unique ID."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID."); + modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>(), It.IsAny<string>()), 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<string>()), 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<string>()), 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<string>()), 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<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>()), 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<string>()), 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<string>(), It.IsAny<string>()), 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<ModMetadataStatus>(), It.IsAny<string>())) - .Callback<ModMetadataStatus, string>((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) + .Setup(p => p.SetStatus(It.IsAny<ModMetadataStatus>(), It.IsAny<string>(), It.IsAny<string>())) + .Callback<ModMetadataStatus, string, string>((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 @@ <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=MemberCanBeMadeStatic_002ELocal/@EntryIndexedValue">DO_NOT_SHOW</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantNameQualifier/@EntryIndexedValue">HINT</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantTypeArgumentsOfMethod/@EntryIndexedValue">HINT</s:String> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RemoveRedundantBraces/@EntryIndexedValue">DO_NOT_SHOW</s:String> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/StaticQualifier/STATIC_MEMBERS_QUALIFY_MEMBERS/@EntryValue">Field, Property, Event, Method</s:String> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_MEMBERS/@EntryValue">Field, Property, Event, Method</s:String> <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/LINE_FEED_AT_FILE_END/@EntryValue">True</s:Boolean> @@ -53,6 +54,7 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=rewriter/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=rewriters/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=SMAPI/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=SMAPI_0027s/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=spawnable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=spritesheet/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=stackable/@EntryIndexedValue">True</s:Boolean> 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 /// <summary>The reason the metadata is invalid, if any.</summary> string Error { get; } + /// <summary>A detailed technical message for <see cref="Error"/>, if any.</summary> + public string ErrorDetails { get; } + /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> bool IsIgnored { get; } @@ -65,8 +68,9 @@ namespace StardewModdingAPI.Framework /// <summary>Set the mod status.</summary> /// <param name="status">The metadata resolution status.</param> /// <param name="error">The reason the metadata is invalid, if any.</param> + /// <param name="errorDetails">A detailed technical message, if any.</param> /// <returns>Return the instance for chaining.</returns> - IModMetadata SetStatus(ModMetadataStatus status, string error = null); + IModMetadata SetStatus(ModMetadataStatus status, string error = null, string errorDetails = null); /// <summary>Set a warning flag for the mod.</summary> /// <param name="warning">The warning to set.</param> 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 +{ + /// <summary>Manages the SMAPI console window and log file.</summary> + internal class LogManager : IDisposable + { + /********* + ** Fields + *********/ + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>Manages console output interception.</summary> + private readonly ConsoleInterceptionManager InterceptionManager = new ConsoleInterceptionManager(); + + /// <summary>Get a named monitor instance.</summary> + private readonly Func<string, Monitor> GetMonitorImpl; + + /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> + 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) + }; + + /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> + 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 + *********/ + /// <summary>The core logger and monitor for SMAPI.</summary> + public Monitor Monitor { get; } + + /// <summary>The core logger and monitor on behalf of the game.</summary> + public Monitor MonitorForGame { get; } + + + /********* + ** Public methods + *********/ + /**** + ** Initialization + ****/ + /// <summary>Construct an instance.</summary> + /// <param name="logPath">The log file path to write.</param> + /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> + /// <param name="writeToConsole">Whether to output log messages to the console.</param> + /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> + /// <param name="isDeveloperMode">Whether to enable full console output for developers.</param> + 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); + } + + /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> + /// <param name="name">The name of the module which will log messages with this instance.</param> + public Monitor GetMonitor(string name) + { + return this.GetMonitorImpl(name); + } + + /// <summary>Set the title of the SMAPI console window.</summary> + /// <param name="title">The new window title.</param> + public void SetConsoleTitle(string title) + { + Console.Title = title; + } + + /**** + ** Console input + ****/ + /// <summary>Run a loop handling console input.</summary> + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + public void RunConsoleInputLoop(CommandManager commandManager, Action reloadTranslations, Action<string> handleInput, Func<bool> continueWhile) + { + // prepare console + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' 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(); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + public void PressAnyKeyToExit() + { + this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + this.PressAnyKeyToExit(showMessage: false); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> + 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 + ****/ + /// <summary>Create a crash marker and duplicate the log into the crash log.</summary> + 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); + } + } + + /// <summary>Write an update alert marker file.</summary> + /// <param name="version">The new version found.</param> + public void WriteUpdateMarker(string version) + { + File.WriteAllText(Constants.UpdateMarker, version); + } + + /// <summary>Check whether SMAPI crashed or detected an update during the last session, and display them in the SMAPI console.</summary> + 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); + } + } + + /// <summary>Log a fatal exception which prevents SMAPI from launching.</summary> + /// <param name="exception">The exception details.</param> + 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 + ****/ + /// <summary>Log the initial header with general SMAPI and system details.</summary> + /// <param name="modsPath">The path from which mods will be loaded.</param> + /// <param name="customSettings">The custom SMAPI settings.</param> + public void LogIntro(string modsPath, IDictionary<string, object> 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}"))}"); + } + + /// <summary>Log details for settings that don't match the default.</summary> + /// <param name="isDeveloperMode">Whether to enable full console output for developers.</param> + /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param> + 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."); + } + + /// <summary>Log info about loaded mods.</summary> + /// <param name="loaded">The full list of loaded content packs and mods.</param> + /// <param name="loadedContentPacks">The loaded content packs.</param> + /// <param name="loadedMods">The loaded mods.</param> + /// <param name="skippedMods">The mods which could not be loaded.</param> + /// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param> + 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); + } + + /// <inheritdoc /> + public void Dispose() + { + this.InterceptionManager.Dispose(); + this.LogFile.Dispose(); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> + /// <param name="gameMonitor">The monitor with which to log messages as the game.</param> + /// <param name="message">The message to log.</param> + 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); + } + + /// <summary>Write a summary of mod warnings to the console and log.</summary> + /// <param name="mods">The loaded mods.</param> + /// <param name="skippedMods">The mods which could not be loaded.</param> + /// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param> + private void LogModWarnings(IEnumerable<IModMetadata> 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<string> skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.HasID() select mod.Manifest.UniqueID, StringComparer.OrdinalIgnoreCase); + foreach (IModMetadata mod in skippedMods) + { + foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds())) + skippedDependencyIds.Add(requiredId); + } + 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<string> labels = new List<string>(); + 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." + ); + } + } + + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="match">Matches mods to include in the warning group.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> + private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> 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(); + } + + /// <summary>Write a mod warning group to the console and log.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="warning">The mod warning to match.</param> + /// <param name="level">The log level for the logged messages.</param> + /// <param name="heading">A brief heading label for the group.</param> + /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> + 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 + *********/ + /// <summary>A console log pattern to replace with a different message.</summary> + private class ReplaceLogPattern + { + /********* + ** Accessors + *********/ + /// <summary>The regex pattern matching the portion of the message to replace.</summary> + public Regex Search { get; } + + /// <summary>The replacement string.</summary> + public string Replacement { get; } + + /// <summary>The log level for the new message.</summary> + public LogLevel LogLevel { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="search">The regex pattern matching the portion of the message to replace.</param> + /// <param name="replacement">The replacement string.</param> + /// <param name="logLevel">The log level for the new message.</param> + 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 *********/ - /// <summary>The mod's display name.</summary> + /// <inheritdoc /> public string DisplayName { get; } - /// <summary>The root path containing mods.</summary> + /// <inheritdoc /> public string RootPath { get; } - /// <summary>The mod's full directory path within the <see cref="RootPath"/>.</summary> + /// <inheritdoc /> public string DirectoryPath { get; } - /// <summary>The <see cref="DirectoryPath"/> relative to the <see cref="RootPath"/>.</summary> + /// <inheritdoc /> public string RelativeDirectoryPath { get; } - /// <summary>The mod manifest.</summary> + /// <inheritdoc /> public IManifest Manifest { get; } - /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> + /// <inheritdoc /> public ModDataRecordVersionedFields DataRecord { get; } - /// <summary>The metadata resolution status.</summary> + /// <inheritdoc /> public ModMetadataStatus Status { get; private set; } - /// <summary>Indicates non-error issues with the mod.</summary> + /// <inheritdoc /> public ModWarning Warnings { get; private set; } - /// <summary>The reason the metadata is invalid, if any.</summary> + /// <inheritdoc /> public string Error { get; private set; } - /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> + /// <inheritdoc /> + public string ErrorDetails { get; private set; } + + /// <inheritdoc /> public bool IsIgnored { get; } - /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> + /// <inheritdoc /> public IMod Mod { get; private set; } - /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + /// <inheritdoc /> public IContentPack ContentPack { get; private set; } - /// <summary>The translations for this mod (if loaded).</summary> + /// <inheritdoc /> public TranslationHelper Translations { get; private set; } - /// <summary>Writes messages to the console and log file as this mod.</summary> + /// <inheritdoc /> public IMonitor Monitor { get; private set; } - /// <summary>The mod-provided API (if any).</summary> + /// <inheritdoc /> public object Api { get; private set; } - /// <summary>The update-check metadata for this mod (if any).</summary> + /// <inheritdoc /> public ModEntryModel UpdateCheckData { get; private set; } - /// <summary>Whether the mod is a content pack.</summary> + /// <inheritdoc /> public bool IsContentPack => this.Manifest?.ContentPackFor != null; @@ -89,28 +92,23 @@ namespace StardewModdingAPI.Framework.ModLoading this.IsIgnored = isIgnored; } - /// <summary>Set the mod status.</summary> - /// <param name="status">The metadata resolution status.</param> - /// <param name="error">The reason the metadata is invalid, if any.</param> - /// <returns>Return the instance for chaining.</returns> - public IModMetadata SetStatus(ModMetadataStatus status, string error = null) + /// <inheritdoc /> + public IModMetadata SetStatus(ModMetadataStatus status, string error = null, string errorDetails = null) { this.Status = status; this.Error = error; + this.ErrorDetails = errorDetails; return this; } - /// <summary>Set a warning flag for the mod.</summary> - /// <param name="warning">The warning to set.</param> + /// <inheritdoc /> public IModMetadata SetWarning(ModWarning warning) { this.Warnings |= warning; return this; } - /// <summary>Set the mod instance.</summary> - /// <param name="mod">The mod instance to set.</param> - /// <param name="translations">The translations for this mod (if loaded).</param> + /// <inheritdoc /> public IModMetadata SetMod(IMod mod, TranslationHelper translations) { if (this.ContentPack != null) @@ -122,10 +120,7 @@ namespace StardewModdingAPI.Framework.ModLoading return this; } - /// <summary>Set the mod instance.</summary> - /// <param name="contentPack">The contentPack instance to set.</param> - /// <param name="monitor">Writes messages to the console and log file.</param> - /// <param name="translations">The translations for this mod (if loaded).</param> + /// <inheritdoc /> public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations) { if (this.Mod != null) @@ -137,29 +132,27 @@ namespace StardewModdingAPI.Framework.ModLoading return this; } - /// <summary>Set the mod-provided API instance.</summary> - /// <param name="api">The mod-provided API.</param> + /// <inheritdoc /> public IModMetadata SetApi(object api) { this.Api = api; return this; } - /// <summary>Set the update-check metadata for this mod.</summary> - /// <param name="data">The update-check metadata.</param> + /// <inheritdoc /> public IModMetadata SetUpdateData(ModEntryModel data) { this.UpdateCheckData = data; return this; } - /// <summary>Whether the mod manifest was loaded (regardless of whether the mod itself was loaded).</summary> + /// <inheritdoc /> public bool HasManifest() { return this.Manifest != null; } - /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary> + /// <inheritdoc /> public bool HasID() { return @@ -167,8 +160,7 @@ namespace StardewModdingAPI.Framework.ModLoading && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID); } - /// <summary>Whether the mod has the given ID.</summary> - /// <param name="id">The mod ID to check.</param> + /// <inheritdoc /> public bool HasID(string id) { return @@ -176,8 +168,7 @@ namespace StardewModdingAPI.Framework.ModLoading && string.Equals(this.Manifest.UniqueID.Trim(), id?.Trim(), StringComparison.OrdinalIgnoreCase); } - /// <summary>Get the defined update keys.</summary> - /// <param name="validOnly">Only return valid update keys.</param> + /// <inheritdoc /> public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false) { foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0]) @@ -188,8 +179,7 @@ namespace StardewModdingAPI.Framework.ModLoading } } - /// <summary>Get the mod IDs that must be installed to load this mod.</summary> - /// <param name="includeOptional">Whether to include optional dependencies.</param> + /// <inheritdoc /> public IEnumerable<string> GetRequiredModIds(bool includeOptional = false) { HashSet<string> required = new HashSet<string>(StringComparer.OrdinalIgnoreCase); @@ -209,14 +199,13 @@ namespace StardewModdingAPI.Framework.ModLoading yield return this.Manifest.ContentPackFor.UniqueID; } - /// <summary>Whether the mod has at least one valid update key set.</summary> + /// <inheritdoc /> public bool HasValidUpdateKeys() { return this.GetUpdateKeys(validOnly: true).Any(); } - /// <summary>Get whether the mod has any of the given warnings which haven't been suppressed in the <see cref="IModMetadata.DataRecord"/>.</summary> - /// <param name="warnings">The warnings to check.</param> + /// <inheritdoc /> public bool HasUnsuppressedWarnings(params ModWarning[] warnings) { return warnings.Any(warning => @@ -225,7 +214,7 @@ namespace StardewModdingAPI.Framework.ModLoading ); } - /// <summary>Get a relative path which includes the root folder name.</summary> + /// <inheritdoc /> 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 *********/ - /// <summary>The log file to which to write messages.</summary> - private readonly LogFileManager LogFile; - - /// <summary>Manages console output interception.</summary> - private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); + /// <summary>Manages the SMAPI console window and log file.</summary> + private readonly LogManager LogManager; /// <summary>The core logger and monitor for SMAPI.</summary> - private readonly Monitor Monitor; - - /// <summary>The core logger and monitor on behalf of the game.</summary> - private readonly Monitor MonitorForGame; + private Monitor Monitor => this.LogManager.Monitor; /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); @@ -89,39 +79,6 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the program has been disposed.</summary> private bool IsDisposed; - /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> - 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) - }; - - /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> - 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 - ) - }; - /// <summary>The mod toolkit used for generic mod interactions.</summary> 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<string, object> 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"); } /// <summary>Initialize SMAPI and mods after the game starts.</summary> @@ -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(); } /// <summary>Handle the game changing locale.</summary> @@ -503,53 +380,18 @@ namespace StardewModdingAPI.Framework mod.Translations.SetLocale(locale, languageCode); } - /// <summary>Run a loop handling console input.</summary> - [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 <cmd>' 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(); - } - /// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary> /// <returns>Returns whether all integrity checks passed.</returns> 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<int, string> 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<string, ModEntryModel> 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 /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> 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<IModMetadata, Tuple<string, string>> skippedMods = new Dictionary<IModMetadata, Tuple<string, string>>(); + IList<IModMetadata> skippedMods = new List<IModMetadata>(); using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings)) { // init HashSet<string> suppressUpdateChecks = new HashSet<string>(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 } } - /// <summary>Write a summary of mod warnings to the console and log.</summary> - /// <param name="mods">The loaded mods.</param> - /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IEnumerable<IModMetadata> mods, IDictionary<IModMetadata, Tuple<string, string>> 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<string> logged = new HashSet<string>(); - 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<IModMetadata, Tuple<string, string>>[] skippedDependencies; - { - HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - HashSet<string> skippedModIds = new HashSet<string>(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<string> labels = new List<string>(); - 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." - ); - } - } - - /// <summary>Write a mod warning group to the console and log.</summary> - /// <param name="mods">The mods to search.</param> - /// <param name="match">Matches mods to include in the warning group.</param> - /// <param name="level">The log level for the logged messages.</param> - /// <param name="heading">A brief heading label for the group.</param> - /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> - /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> - private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> 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(); - } - - /// <summary>Write a mod warning group to the console and log.</summary> - /// <param name="mods">The mods to search.</param> - /// <param name="warning">The mod warning to match.</param> - /// <param name="level">The log level for the logged messages.</param> - /// <param name="heading">A brief heading label for the group.</param> - /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> - void LogModWarningGroup(IModMetadata[] mods, ModWarning warning, LogLevel level, string heading, params string[] blurb) - { - this.LogModWarningGroup(mods, mod => mod.HasUnsuppressedWarnings(warning), level, heading, blurb); - } - /// <summary>Load a mod's entry class.</summary> /// <param name="modAssembly">The mod assembly.</param> /// <param name="mod">The loaded instance.</param> @@ -1366,64 +995,6 @@ namespace StardewModdingAPI.Framework return translations; } - /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> - /// <param name="gameMonitor">The monitor with which to log messages as the game.</param> - /// <param name="message">The message to log.</param> - 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); - } - - /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - private void PressAnyKeyToExit() - { - this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); - this.PressAnyKeyToExit(showMessage: false); - } - - /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> - 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); - } - - /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> - /// <param name="name">The name of the module which will log messages with this instance.</param> - 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 - }; - } - /// <summary>Get the absolute path to the next available log file.</summary> private string GetLogPath() { @@ -1474,36 +1045,5 @@ namespace StardewModdingAPI.Framework } } } - - /// <summary>A console log pattern to replace with a different message.</summary> - private class ReplaceLogPattern - { - /********* - ** Accessors - *********/ - /// <summary>The regex pattern matching the portion of the message to replace.</summary> - public Regex Search { get; } - - /// <summary>The replacement string.</summary> - public string Replacement { get; } - - /// <summary>The log level for the new message.</summary> - public LogLevel LogLevel { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="search">The regex pattern matching the portion of the message to replace.</param> - /// <param name="replacement">The replacement string.</param> - /// <param name="logLevel">The log level for the new message.</param> - public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel) - { - this.Search = search; - this.Replacement = replacement; - this.LogLevel = logLevel; - } - } } } |