summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.Tests/Core/ModResolverTests.cs22
-rw-r--r--src/SMAPI.sln.DotSettings2
-rw-r--r--src/SMAPI/Framework/IModMetadata.cs6
-rw-r--r--src/SMAPI/Framework/Logging/LogManager.cs586
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs83
-rw-r--r--src/SMAPI/Framework/SCore.cs590
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;
- }
- }
}
}