using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
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.Events;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Logging;
using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Patching;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Serialisation;
using StardewModdingAPI.Internal;
using StardewModdingAPI.Patches;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Serialisation;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using Object = StardewValley.Object;
using ThreadState = System.Threading.ThreadState;
namespace StardewModdingAPI.Framework
{
/// The core class which initialises and manages SMAPI.
internal class SCore : IDisposable
{
/*********
** Fields
*********/
/// The log file to which to write messages.
private readonly LogFileManager LogFile;
/// Manages console output interception.
private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager();
/// The core logger and monitor for SMAPI.
private readonly Monitor Monitor;
/// The core logger and monitor on behalf of the game.
private readonly Monitor MonitorForGame;
/// Tracks whether the game should exit immediately and any pending initialisation should be cancelled.
private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource();
/// Simplifies access to private game code.
private readonly Reflector Reflection = new Reflector();
/// The SMAPI configuration settings.
private readonly SConfig Settings;
/// The underlying game instance.
private SGame GameInstance;
/// The underlying content manager.
private ContentCoordinator ContentCore => this.GameInstance.ContentCore;
/// Tracks the installed mods.
/// This is initialised after the game starts.
private readonly ModRegistry ModRegistry = new ModRegistry();
/// Manages SMAPI events for mods.
private readonly EventManager EventManager;
/// Whether the game is currently running.
private bool IsGameRunning;
/// Whether the program has been disposed.
private bool IsDisposed;
/// Regex patterns which match console messages to suppress from the console and log.
private readonly Regex[] SuppressConsolePatterns =
{
new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
};
/// Regex patterns which match console messages to show a more friendly error for.
private readonly Tuple[] ReplaceConsolePatterns =
{
Tuple.Create(
new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.", RegexOptions.Compiled | RegexOptions.CultureInvariant),
#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.Error
)
};
/// The mod toolkit used for generic mod interactions.
private readonly ModToolkit Toolkit = new ModToolkit();
/// The path to search for mods.
private string ModsPath => Constants.ModsPath;
/*********
** Accessors
*********/
/// Manages deprecation warnings.
/// This is initialised after the game starts. This is accessed directly because it's not part of the normal class model.
internal static DeprecationManager DeprecationManager { get; private set; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The path to search for mods.
/// Whether to output log messages to the console.
public SCore(string modsPath, bool writeToConsole)
{
// init paths
this.VerifyPath(modsPath);
this.VerifyPath(Constants.LogDir);
Constants.ModsPath = modsPath;
// init log file
this.PurgeNormalLogs();
string logPath = this.GetLogPath();
// init basics
this.Settings = JsonConvert.DeserializeObject(File.ReadAllText(Constants.ApiConfigPath));
this.LogFile = new LogFileManager(logPath);
this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging)
{
WriteToConsole = writeToConsole,
ShowTraceInConsole = this.Settings.DeveloperMode,
ShowFullStampInConsole = this.Settings.DeveloperMode
};
this.MonitorForGame = this.GetSecondaryMonitor("game");
this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
// 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}");
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);
// 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;
}
#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
}
/// Launch SMAPI.
[HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions
public void RunInteractively()
{
// initialise SMAPI
try
{
#if !SMAPI_3_0_STRICT
// hook up events
ContentEvents.Init(this.EventManager);
ControlEvents.Init(this.EventManager);
GameEvents.Init(this.EventManager);
GraphicsEvents.Init(this.EventManager);
InputEvents.Init(this.EventManager);
LocationEvents.Init(this.EventManager);
MenuEvents.Init(this.EventManager);
MineEvents.Init(this.EventManager);
MultiplayerEvents.Init(this.EventManager);
PlayerEvents.Init(this.EventManager);
SaveEvents.Init(this.EventManager);
SpecialisedEvents.Init(this.EventManager);
TimeEvents.Init(this.EventManager);
#endif
// init JSON parser
JsonConverter[] converters = {
new ColorConverter(),
new PointConverter(),
new RectangleConverter()
};
foreach (JsonConverter converter in converters)
this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter);
// add error handlers
#if SMAPI_FOR_WINDOWS
Application.ThreadException += (sender, e) => this.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error);
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
#endif
AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
// add more leniant assembly resolvers
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
// override game
SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper);
this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, SCore.DeprecationManager, this.OnLocaleChanged, this.InitialiseAfterGameStart, this.Dispose);
StardewValley.Program.gamePtr = this.GameInstance;
// apply game patches
new GamePatcher(this.Monitor).Apply(
new DialogueErrorPatch(this.MonitorForGame, this.Reflection),
new ObjectErrorPatch(),
new LoadForNewGamePatch(this.Reflection, this.GameInstance.OnLoadStageChanged)
);
// add exit handler
new Thread(() =>
{
this.CancellationTokenSource.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()}");
}
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}";
#if SMAPI_3_0_STRICT
this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]";
Console.Title += " [SMAPI 3.0 strict mode]";
#endif
}
catch (Exception ex)
{
this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error);
this.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);
}
// show details if game crashed during last session
if (File.Exists(Constants.FatalCrashMarker))
{
this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error);
this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", 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);
}
// start game
this.Monitor.Log("Starting game...", LogLevel.Debug);
try
{
this.IsGameRunning = true;
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();
}
finally
{
this.Dispose();
}
}
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
public void Dispose()
{
// skip if already disposed
if (this.IsDisposed)
return;
this.IsDisposed = true;
this.Monitor.Log("Disposing...", LogLevel.Trace);
// dispose mod data
foreach (IModMetadata mod in this.ModRegistry.GetAll())
{
try
{
(mod.Mod as IDisposable)?.Dispose();
}
catch (Exception ex)
{
mod.LogAsMod($"Mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn);
}
}
// dispose core components
this.IsGameRunning = false;
this.ConsoleManager?.Dispose();
this.ContentCore?.Dispose();
this.CancellationTokenSource?.Dispose();
this.GameInstance?.Dispose();
this.LogFile?.Dispose();
// end game (moved from Game1.OnExiting to let us clean up first)
Process.GetCurrentProcess().Kill();
}
/*********
** Private methods
*********/
/// Initialise SMAPI and mods after the game starts.
private void InitialiseAfterGameStart()
{
// add headers
#if SMAPI_3_0_STRICT
this.Monitor.Log($"You're running SMAPI 3.0 strict mode, so most mods won't work correctly. If that wasn't intended, install the normal version of SMAPI from https://smapi.io instead.", LogLevel.Warn);
#endif
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.");
// validate XNB integrity
if (!this.ValidateContentIntegrity())
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);
// load mod data
ModToolkit toolkit = new ModToolkit();
ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath);
// load mods
{
this.Monitor.Log("Loading mod metadata...", LogLevel.Trace);
ModResolver resolver = new ModResolver();
// load manifests
IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray();
// filter out ignored mods
foreach (IModMetadata mod in mods.Where(p => p.IsIgnored))
this.Monitor.Log($" Skipped {mod.RelativeDirectoryPath} (folder name starts with a dot).", LogLevel.Trace);
mods = mods.Where(p => !p.IsIgnored).ToArray();
// load mods
resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl);
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
// write metadata file
if (this.Settings.DumpMetadata)
{
ModFolderExport export = new ModFolderExport
{
Exported = DateTime.UtcNow.ToString("O"),
ApiVersion = Constants.ApiVersion.ToString(),
GameVersion = Constants.GameVersion.ToString(),
ModFolderPath = this.ModsPath,
Mods = mods
};
this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export);
}
// check for updates
this.CheckForUpdatesAsync(mods);
}
if (this.Monitor.IsExiting)
{
this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn);
return;
}
// 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";
#if SMAPI_3_0_STRICT
this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]";
Console.Title += " [SMAPI 3.0 strict mode]";
#endif
// start SMAPI console
new Thread(this.RunConsoleLoop).Start();
}
/// Handle the game changing locale.
private void OnLocaleChanged()
{
// get locale
string locale = this.ContentCore.GetLocale();
LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language;
// update mod translation helpers
foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false))
(mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode);
}
/// Run a loop handling console input.
[SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")]
private void RunConsoleLoop()
{
// prepare console
this.Monitor.Log("Type 'help' for help, or 'help ' for a command's usage", LogLevel.Info);
this.GameInstance.CommandManager.Add(null, "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help \n- cmd: The name of a command whose documentation to display.", this.HandleCommand);
this.GameInstance.CommandManager.Add(null, "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand);
// 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.Monitor.IsExiting)
Thread.Sleep(1000 / 10);
if (inputThread.ThreadState == ThreadState.Running)
inputThread.Abort();
}
/// Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.
/// Returns whether all integrity checks passed.
private bool ValidateContentIntegrity()
{
this.Monitor.Log("Detecting common issues...", LogLevel.Trace);
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);
foreach (KeyValuePair entry in Game1.objectInformation)
{
// must not be empty
if (string.IsNullOrWhiteSpace(entry.Value))
{
LogIssue(entry.Key, "entry is empty");
hasObjectIssues = true;
continue;
}
// require core fields
string[] fields = entry.Value.Split('/');
if (fields.Length < Object.objectInfoDescriptionIndex + 1)
{
LogIssue(entry.Key, "too few fields for an object");
hasObjectIssues = true;
continue;
}
// check min length for specific types
switch (fields[Object.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0])
{
case "Cooking":
if (fields.Length < Object.objectInfoBuffDurationIndex + 1)
{
LogIssue(entry.Key, "too few fields for a cooking item");
hasObjectIssues = true;
}
break;
}
}
// log error
if (hasObjectIssues)
{
issuesFound = true;
this.Monitor.Log(@"Your Content\Data\ObjectInformation.xnb file seems to be broken or outdated.", LogLevel.Warn);
}
}
return !issuesFound;
}
/// Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.
/// The mods to include in the update check (if eligible).
private void CheckForUpdatesAsync(IModMetadata[] mods)
{
if (!this.Settings.CheckForUpdates)
return;
new Thread(() =>
{
// create client
string url = this.Settings.WebApiBaseUrl;
#if !SMAPI_FOR_WINDOWS
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);
// check SMAPI version
ISemanticVersion updateFound = null;
try
{
ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value;
ISemanticVersion latestStable = response.Main?.Version;
ISemanticVersion latestBeta = response.Optional?.Version;
if (latestStable == null && 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)}");
}
else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel))
{
updateFound = latestBeta;
this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert);
}
else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel))
{
updateFound = latestStable;
this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert);
}
else
this.Monitor.Log(" SMAPI okay.", LogLevel.Trace);
}
catch (Exception ex)
{
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()}"
);
}
// show update message on next launch
if (updateFound != null)
File.WriteAllText(Constants.UpdateMarker, updateFound.ToString());
// check mod versions
if (mods.Any())
{
try
{
HashSet suppressUpdateChecks = new HashSet(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
// prepare search model
List searchMods = new List();
foreach (IModMetadata mod in mods)
{
if (!mod.HasID() || suppressUpdateChecks.Contains(mod.Manifest.UniqueID))
continue;
string[] updateKeys = mod
.GetUpdateKeys(validOnly: true)
.Select(p => p.ToString())
.ToArray();
searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.ToArray()));
}
// fetch results
this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace);
IDictionary results = client.GetModInfo(searchMods.ToArray());
// extract update alerts & errors
var updates = new List>();
var errors = new StringBuilder();
foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName))
{
// link to update-check data
if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result))
continue;
mod.SetUpdateData(result);
// handle errors
if (result.Errors != null && result.Errors.Any())
{
errors.AppendLine(result.Errors.Length == 1
? $" {mod.DisplayName}: {result.Errors[0]}"
: $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}"
);
}
// parse versions
bool useBetaInfo = result.HasBetaInfo && Constants.ApiVersion.IsPrerelease();
ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version;
ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version;
ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version;
ISemanticVersion unofficialVersion = useBetaInfo ? result.UnofficialForBeta?.Version : result.Unofficial?.Version;
// show update alerts
if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true))
updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url));
else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease()))
updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url));
else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed))
updates.Add(Tuple.Create(mod, unofficialVersion, useBetaInfo ? result.UnofficialForBeta?.Url : result.Unofficial?.Url));
}
// show update errors
if (errors.Length != 0)
this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace);
// show update alerts
if (updates.Any())
{
this.Monitor.Newline();
this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert);
foreach (var entry in updates)
{
IModMetadata mod = entry.Item1;
ISemanticVersion newVersion = entry.Item2;
string newUrl = entry.Item3;
this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert);
}
}
else
this.Monitor.Log(" All mods up to date.", LogLevel.Trace);
}
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()
);
}
}
}).Start();
}
/// Get whether a given version should be offered to the user as an update.
/// The current semantic version.
/// The target semantic version.
/// Whether the user enabled the beta channel and should be offered pre-release updates.
private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel)
{
return
newVersion != null
&& newVersion.IsNewerThan(currentVersion)
&& (useBetaChannel || !newVersion.IsPrerelease());
}
/// Create a directory path if it doesn't exist.
/// The directory path.
private void VerifyPath(string path)
{
try
{
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
}
catch (Exception ex)
{
// note: this happens before this.Monitor is initialised
Console.WriteLine($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}");
}
}
/// Load and hook up the given mods.
/// The mods to load.
/// The JSON helper with which to read mods' JSON files.
/// The content manager to use for mod content.
/// Handles access to SMAPI's internal mod metadata list.
private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase)
{
this.Monitor.Log("Loading mods...", LogLevel.Trace);
// load mods
IDictionary> skippedMods = new Dictionary>();
using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings))
{
// init
HashSet suppressUpdateChecks = new HashSet(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
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)
{
if (!this.TryLoadMod(contentPack, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails))
LogSkip(contentPack, errorPhrase, errorDetails);
}
}
IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray();
IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray();
// 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}" : "")
+ (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "")
+ (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
LogLevel.Info
);
}
this.Monitor.Newline();
}
// log mod warnings
this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods);
// initialise translations
this.ReloadTranslations(loadedMods);
// initialise loaded non-content-pack mods
foreach (IModMetadata metadata in loadedMods)
{
// add interceptors
if (metadata.Mod.Helper.Content is ContentHelper helper)
{
// ReSharper disable SuspiciousTypeConversion.Global
if (metadata.Mod is IAssetEditor editor)
helper.ObservableAssetEditors.Add(editor);
if (metadata.Mod is IAssetLoader loader)
helper.ObservableAssetLoaders.Add(loader);
// ReSharper restore SuspiciousTypeConversion.Global
this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors;
this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders;
}
// call entry method
try
{
IMod mod = metadata.Mod;
mod.Entry(mod.Helper);
}
catch (Exception ex)
{
metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
// get mod API
try
{
object api = metadata.Mod.GetApi();
if (api != null && !api.GetType().IsPublic)
{
api = null;
this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn);
}
if (api != null)
this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace);
metadata.SetApi(api);
}
catch (Exception ex)
{
this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {ex.GetLogSummary()}", LogLevel.Error);
}
}
// invalidate cache entries when needed
// (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.)
foreach (IModMetadata metadata in loadedMods)
{
if (metadata.Mod.Helper.Content is ContentHelper helper)
{
helper.ObservableAssetEditors.CollectionChanged += (sender, e) =>
{
if (e.NewItems?.Count > 0)
{
this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace);
this.ContentCore.InvalidateCacheFor(e.NewItems.Cast().ToArray(), new IAssetLoader[0]);
}
};
helper.ObservableAssetLoaders.CollectionChanged += (sender, e) =>
{
if (e.NewItems?.Count > 0)
{
this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace);
this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast().ToArray());
}
};
}
}
// reset cache now if any editors or loaders were added during entry
IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray();
IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray();
if (editors.Any() || loaders.Any())
{
this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace);
this.ContentCore.InvalidateCacheFor(editors, loaders);
}
// unlock mod integrations
this.ModRegistry.AreAllModsInitialised = true;
}
/// Load a given mod.
/// The mod to load.
/// The mods being loaded.
/// Preprocesses and loads mod assemblies
/// Generates proxy classes to access mod APIs through an arbitrary interface.
/// The JSON helper with which to read mods' JSON files.
/// The content manager to use for mod content.
/// Handles access to SMAPI's internal mod metadata list.
/// The mod IDs to ignore when validating update keys.
/// The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).
/// More detailed details about the error intended for developers (if any).
/// Returns whether the mod was successfully loaded.
private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet suppressUpdateChecks, out string errorReasonPhrase, out string errorDetails)
{
errorDetails = null;
// log entry
{
string relativePath = PathUtilities.GetRelativePath(this.ModsPath, mod.DirectoryPath);
if (mod.IsContentPack)
this.Monitor.Log($" {mod.DisplayName} ({relativePath}) [content pack]...", LogLevel.Trace);
else if (mod.Manifest?.EntryDll != null)
this.Monitor.Log($" {mod.DisplayName} ({relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid
else
this.Monitor.Log($" {mod.DisplayName} ({relativePath})...", LogLevel.Trace);
}
// add warning for missing update key
if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest.UniqueID) && !mod.HasValidUpdateKeys())
mod.SetWarning(ModWarning.NoUpdateKeys);
// validate status
if (mod.Status == ModMetadataStatus.Failed)
{
this.Monitor.Log($" Failed: {mod.Error}", LogLevel.Trace);
errorReasonPhrase = mod.Error;
return false;
}
#if !SMAPI_3_0_STRICT
// add deprecation warning for old version format
{
if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat)
SCore.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.PendingRemoval);
}
#endif
// validate dependencies
// Although dependences are validated before mods are loaded, a dependency may have failed to load.
if (mod.Manifest.Dependencies?.Any() == true)
{
foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired))
{
if (this.ModRegistry.Get(dependency.UniqueID) == null)
{
string dependencyName = mods
.FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID))
?.DisplayName ?? dependency.UniqueID;
errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded.";
return false;
}
}
}
// load as content pack
if (mod.IsContentPack)
{
IManifest manifest = mod.Manifest;
IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName);
IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor);
IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, jsonHelper);
mod.SetMod(contentPack, monitor);
this.ModRegistry.Add(mod);
errorReasonPhrase = null;
return true;
}
// load as mod
else
{
IManifest manifest = mod.Manifest;
// load mod
string assemblyPath = manifest?.EntryDll != null
? Path.Combine(mod.DirectoryPath, manifest.EntryDll)
: null;
Assembly modAssembly;
try
{
modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible);
this.ModRegistry.TrackAssemblies(mod, modAssembly);
}
catch (IncompatibleInstructionException) // details already in trace logs
{
string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://mods.smapi.io" }.Where(p => p != null).ToArray();
errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}";
return false;
}
catch (SAssemblyLoadFailedException ex)
{
errorReasonPhrase = $"it DLL couldn't be loaded: {ex.Message}";
return false;
}
catch (Exception ex)
{
errorReasonPhrase = "its DLL couldn't be loaded.";
errorDetails = $"Error: {ex.GetLogSummary()}";
return false;
}
// initialise mod
try
{
// get mod instance
if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase))
return false;
// get content packs
IContentPack[] GetContentPacks()
{
if (!this.ModRegistry.AreAllModsLoaded)
throw new InvalidOperationException("Can't access content packs before SMAPI finishes loading mods.");
return this.ModRegistry
.GetAll(assemblyMods: false)
.Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor.UniqueID))
.Select(p => p.ContentPack)
.ToArray();
}
// init mod helpers
IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName);
IModHelper modHelper;
{
IModEvents events = new ModEvents(mod, this.EventManager);
ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager);
IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor);
IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy(GetContentPacks), CreateFakeContentPack);
IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper);
IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection);
IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer);
ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language);
IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest)
{
IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name);
IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor);
return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper);
}
modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
}
// init mod
modEntry.ModManifest = manifest;
modEntry.Helper = modHelper;
modEntry.Monitor = monitor;
// track mod
mod.SetMod(modEntry);
this.ModRegistry.Add(mod);
return true;
}
catch (Exception ex)
{
errorReasonPhrase = $"initialisation failed:\n{ex.GetLogSummary()}";
return false;
}
}
}
/// Write a summary of mod warnings to the console and log.
/// The loaded mods.
/// The mods which were skipped, along with the friendly and developer reasons.
private void LogModWarnings(IModMetadata[] mods, IDictionary> skippedMods)
{
// get mods with warnings
IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray();
if (!modsWithWarnings.Any() && !skippedMods.Any())
return;
// log intro
{
int count = modsWithWarnings.Union(skippedMods.Keys).Count();
this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info);
}
// log skipped mods
if (skippedMods.Any())
{
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();
HashSet logged = new HashSet();
foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
{
IModMetadata mod = pair.Key;
string errorReason = pair.Value.Item1;
string errorDetails = pair.Value.Item2;
string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
if (!logged.Add($"{message}|{errorDetails}"))
continue; // skip duplicate messages (e.g. if multiple copies of the mod are installed)
this.Monitor.Log(message, LogLevel.Error);
if (errorDetails != null)
this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
}
this.Monitor.Newline();
}
// log warnings
if (modsWithWarnings.Any())
{
// issue block format logic
void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb)
{
IModMetadata[] matches = modsWithWarnings.Where(p => p.Warnings.HasFlag(warning)).ToArray();
if (!matches.Any())
return;
this.Monitor.Log(" " + heading, logLevel);
this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel);
foreach (string line in blurb)
this.Monitor.Log(" " + line, logLevel);
this.Monitor.Newline();
foreach (IModMetadata match in matches)
this.Monitor.Log($" - {match.DisplayName}", logLevel);
this.Monitor.Newline();
}
// supported issues
LogWarningGroup(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."
);
LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser",
"These mods change the save serialiser. They may corrupt your save files, or make them unusable if",
"you uninstall these mods."
);
if (this.Settings.ParanoidWarnings)
{
LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly",
"These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be",
"legitimate and innocent usage; this warning is meaningless without further investigation.)"
);
LogWarningGroup(ModWarning.AccessesShell, LogLevel.Warn, "Accesses shell/process directly",
"These mods directly access the OS shell or processes, and you enabled paranoid warnings. (Note that",
"this may be legitimate and innocent usage; this warning is meaningless without further investigation.)"
);
}
LogWarningGroup(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."
);
LogWarningGroup(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."
);
LogWarningGroup(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."
);
LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform",
"These mods use the 'dynamic' keyword, and won't work on Linux/Mac."
);
}
}
/// Load a mod's entry class.
/// The mod assembly.
/// The loaded instance.
/// The error indicating why loading failed (if applicable).
/// Returns whether the mod entry class was successfully loaded.
private bool TryLoadModEntry(Assembly modAssembly, out Mod mod, out string error)
{
mod = null;
// find type
TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray();
if (modEntries.Length == 0)
{
error = $"its DLL has no '{nameof(Mod)}' subclass.";
return false;
}
if (modEntries.Length > 1)
{
error = $"its DLL contains multiple '{nameof(Mod)}' subclasses.";
return false;
}
// get implementation
mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString());
if (mod == null)
{
error = "its entry class couldn't be instantiated.";
return false;
}
error = null;
return true;
}
/// Reload translations for all mods.
/// The mods for which to reload translations.
private void ReloadTranslations(IEnumerable mods)
{
JsonHelper jsonHelper = this.Toolkit.JsonHelper;
foreach (IModMetadata metadata in mods)
{
if (metadata.IsContentPack)
throw new InvalidOperationException("Can't reload translations for a content pack.");
// read translation files
IDictionary> translations = new Dictionary>();
DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n"));
if (translationsDir.Exists)
{
foreach (FileInfo file in translationsDir.EnumerateFiles("*.json"))
{
string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim());
try
{
if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary data))
translations[locale] = data;
else
metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn);
}
catch (Exception ex)
{
metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn);
}
}
}
// validate translations
foreach (string locale in translations.Keys.ToArray())
{
// skip empty files
if (translations[locale] == null || !translations[locale].Keys.Any())
{
metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn);
translations.Remove(locale);
continue;
}
// handle duplicates
HashSet keys = new HashSet(StringComparer.InvariantCultureIgnoreCase);
HashSet duplicateKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase);
foreach (string key in translations[locale].Keys.ToArray())
{
if (!keys.Add(key))
{
duplicateKeys.Add(key);
translations[locale].Remove(key);
}
}
if (duplicateKeys.Any())
metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn);
}
// update translation
TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation;
translationHelper.SetTranslations(translations);
}
}
/// The method called when the user submits a core SMAPI command in the console.
/// The command name.
/// The command arguments.
private void HandleCommand(string name, string[] arguments)
{
switch (name)
{
case "help":
if (arguments.Any())
{
Command result = this.GameInstance.CommandManager.Get(arguments[0]);
if (result == null)
this.Monitor.Log("There's no command with that name.", LogLevel.Error);
else
this.Monitor.Log($"{result.Name}: {result.Documentation}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info);
}
else
{
string message = "The following commands are registered:\n";
IGrouping[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray();
foreach (var group in groups)
{
string modName = group.Key ?? "SMAPI";
string[] commandNames = group.ToArray();
message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n";
}
message += "For more information about a command, type 'help command_name'.";
this.Monitor.Log(message, LogLevel.Info);
}
break;
case "reload_i18n":
this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false));
this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info);
break;
default:
throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'.");
}
}
/// Redirect messages logged directly to the console to the given monitor.
/// The monitor with which to log messages as the game.
/// The message to log.
private void HandleConsoleMessage(IMonitor gameMonitor, string message)
{
// detect exception
LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace;
// ignore suppressed message
if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message)))
return;
// show friendly error if applicable
foreach (var entry in this.ReplaceConsolePatterns)
{
if (entry.Item1.IsMatch(message))
{
this.Monitor.Log(entry.Item2, entry.Item3);
gameMonitor.Log(message, LogLevel.Trace);
return;
}
}
// forward to monitor
gameMonitor.Log(message, level);
}
/// Show a 'press any key to exit' message, and exit when they press a key.
private void PressAnyKeyToExit()
{
this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info);
this.PressAnyKeyToExit(showMessage: false);
}
/// Show a 'press any key to exit' message, and exit when they press a key.
/// Whether to print a 'press any key to exit' message to the console.
private void PressAnyKeyToExit(bool showMessage)
{
if (showMessage)
Console.WriteLine("Game has ended. Press any key to exit.");
Thread.Sleep(100);
Console.ReadKey();
Environment.Exit(0);
}
/// Get a monitor instance derived from SMAPI's current settings.
/// The name of the module which will log messages with this instance.
private Monitor GetSecondaryMonitor(string name)
{
return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging)
{
WriteToConsole = this.Monitor.WriteToConsole,
ShowTraceInConsole = this.Settings.DeveloperMode,
ShowFullStampInConsole = this.Settings.DeveloperMode
};
}
/// Get the absolute path to the next available log file.
private string GetLogPath()
{
// default path
{
FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}"));
if (!defaultFile.Exists)
return defaultFile.FullName;
}
// get first disambiguated path
for (int i = 2; i < int.MaxValue; i++)
{
FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}"));
if (!file.Exists)
return file.FullName;
}
// should never happen
throw new InvalidOperationException("Could not find an available log path.");
}
/// Delete normal (non-crash) log files created by SMAPI.
private void PurgeNormalLogs()
{
DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir);
if (!logsDir.Exists)
return;
foreach (FileInfo logFile in logsDir.EnumerateFiles())
{
// skip non-SMAPI file
if (!logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase))
continue;
// skip crash log
if (logFile.FullName == Constants.FatalCrashLog)
continue;
// delete file
try
{
FileUtilities.ForceDelete(logFile);
}
catch (IOException)
{
// ignore file if it's in use
}
}
}
}
}