summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/SMAPI/Framework/SCore.cs1283
-rw-r--r--src/SMAPI/Program.cs1281
-rw-r--r--src/SMAPI/StardewModdingAPI.csproj1
3 files changed, 1304 insertions, 1261 deletions
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
new file mode 100644
index 00000000..a9ec5ee4
--- /dev/null
+++ b/src/SMAPI/Framework/SCore.cs
@@ -0,0 +1,1283 @@
+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.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
+{
+ /// <summary>The core class which initialises and manages SMAPI.</summary>
+ internal class SCore : IDisposable
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <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>The core logger and monitor for SMAPI.</summary>
+ private readonly Monitor Monitor;
+
+ /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary>
+ private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource();
+
+ /// <summary>Simplifies access to private game code.</summary>
+ private readonly Reflector Reflection = new Reflector();
+
+ /// <summary>The SMAPI configuration settings.</summary>
+ private readonly SConfig Settings;
+
+ /// <summary>The underlying game instance.</summary>
+ private SGame GameInstance;
+
+ /// <summary>The underlying content manager.</summary>
+ private ContentCoordinator ContentCore => this.GameInstance.ContentCore;
+
+ /// <summary>Tracks the installed mods.</summary>
+ /// <remarks>This is initialised after the game starts.</remarks>
+ private readonly ModRegistry ModRegistry = new ModRegistry();
+
+ /// <summary>Manages deprecation warnings.</summary>
+ /// <remarks>This is initialised after the game starts.</remarks>
+ private DeprecationManager DeprecationManager;
+
+ /// <summary>Manages SMAPI events for mods.</summary>
+ private readonly EventManager EventManager;
+
+ /// <summary>Whether the game is currently running.</summary>
+ private bool IsGameRunning;
+
+ /// <summary>Whether the program has been disposed.</summary>
+ private bool IsDisposed;
+
+ /// <summary>Regex patterns which match console 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: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ };
+
+ /// <summary>The mod toolkit used for generic mod interactions.</summary>
+ private readonly ModToolkit Toolkit = new ModToolkit();
+
+ /// <summary>The path to search for mods.</summary>
+ private readonly string ModsPath;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="modsPath">The path to search for mods.</param>
+ /// <param name="writeToConsole">Whether to output log messages to the console.</param>
+ public SCore(string modsPath, bool writeToConsole)
+ {
+ // init paths
+ this.VerifyPath(modsPath);
+ this.VerifyPath(Constants.LogDir);
+ this.ModsPath = modsPath;
+
+ // init log file
+ this.PurgeLogFiles();
+ string logPath = this.GetLogPath();
+
+ // init basics
+ this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
+ this.LogFile = new LogFileManager(logPath);
+ this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme)
+ {
+ WriteToConsole = writeToConsole,
+ ShowTraceInConsole = this.Settings.DeveloperMode,
+ ShowFullStampInConsole = this.Settings.DeveloperMode
+ };
+ this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
+
+ // 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 game version
+ if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion))
+ {
+ this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error);
+ this.PressAnyKeyToExit();
+ return;
+ }
+ if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion))
+ {
+ this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error);
+ this.PressAnyKeyToExit();
+ return;
+ }
+
+ // apply game patches
+ new GamePatcher(this.Monitor).Apply(
+ // new GameLocationPatch()
+ );
+ }
+
+ /// <summary>Launch SMAPI.</summary>
+ [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions
+ public void RunInteractively()
+ {
+ // initialise SMAPI
+ try
+ {
+ // 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);
+
+ // 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.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose);
+ StardewValley.Program.gamePtr = this.GameInstance;
+
+ // 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();
+
+ // hook into game events
+ ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged();
+
+ // 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}";
+ }
+ 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: http://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 (Exception ex)
+ {
+ this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error);
+ this.PressAnyKeyToExit();
+ }
+ finally
+ {
+ this.Dispose();
+ }
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ 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
+ *********/
+ /// <summary>Initialise SMAPI and mods after the game starts.</summary>
+ private void InitialiseAfterGameStart()
+ {
+ // load settings
+ this.GameInstance.VerboseLogging = this.Settings.VerboseLogging;
+
+ // load core components
+ this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
+
+ // redirect direct console output
+ {
+ Monitor monitor = this.GetSecondaryMonitor("game");
+ if (monitor.WriteToConsole)
+ this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message);
+ }
+
+ // add headers
+ if (this.Settings.DeveloperMode)
+ this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may 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.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();
+ resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl);
+
+ // process dependencies
+ mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
+
+ // load mods
+ 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";
+
+ // start SMAPI console
+ new Thread(this.RunConsoleLoop).Start();
+ }
+
+ /// <summary>Handle the game changing locale.</summary>
+ 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);
+ }
+
+ /// <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("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand);
+ this.GameInstance.CommandManager.Add("SMAPI", "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();
+ }
+
+ /// <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);
+ 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<int, string> 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;
+ }
+
+ /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary>
+ /// <param name="mods">The mods to include in the update check (if eligible).</param>
+ 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<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
+
+ // prepare search model
+ List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>();
+ foreach (IModMetadata mod in mods)
+ {
+ if (!mod.HasID())
+ continue;
+
+ string[] updateKeys = mod.Manifest.UpdateKeys ?? new string[0];
+ searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.Except(suppressUpdateChecks).ToArray()));
+ }
+
+ // fetch results
+ this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace);
+ IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray());
+
+ // extract update alerts & errors
+ var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>();
+ 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
+ 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 = 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, 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();
+ }
+
+ /// <summary>Get whether a given version should be offered to the user as an update.</summary>
+ /// <param name="currentVersion">The current semantic version.</param>
+ /// <param name="newVersion">The target semantic version.</param>
+ /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param>
+ private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel)
+ {
+ return
+ newVersion != null
+ && newVersion.IsNewerThan(currentVersion)
+ && (useBetaChannel || !newVersion.IsPrerelease());
+ }
+
+ /// <summary>Create a directory path if it doesn't exist.</summary>
+ /// <param name="path">The directory path.</param>
+ private void VerifyPath(string path)
+ {
+ try
+ {
+ if (!Directory.Exists(path))
+ Directory.CreateDirectory(path);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ /// <summary>Load and hook up the given mods.</summary>
+ /// <param name="mods">The mods to load.</param>
+ /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param>
+ /// <param name="contentCore">The content manager to use for mod content.</param>
+ /// <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);
+
+ HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
+ IDictionary<IModMetadata, string[]> skippedMods = new Dictionary<IModMetadata, string[]>();
+ void TrackSkip(IModMetadata mod, string userReasonPhrase, string devReasonPhrase = null) => skippedMods[mod] = new[] { userReasonPhrase, devReasonPhrase };
+
+ // load content packs
+ foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack))
+ {
+ this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)})...", LogLevel.Trace);
+
+ // show warning for missing update key
+ if (metadata.HasManifest() && !metadata.HasUpdateKeys())
+ metadata.SetWarning(ModWarning.NoUpdateKeys);
+
+ // validate status
+ if (metadata.Status == ModMetadataStatus.Failed)
+ {
+ this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
+ TrackSkip(metadata, metadata.Error);
+ continue;
+ }
+
+ // load mod as content pack
+ IManifest manifest = metadata.Manifest;
+ IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
+ IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
+ IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper);
+ metadata.SetMod(contentPack, monitor);
+ this.ModRegistry.Add(metadata);
+ }
+ IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray();
+
+ // load mods
+ {
+ // get content packs by mod ID
+ IDictionary<string, IContentPack[]> contentPacksByModID =
+ loadedContentPacks
+ .GroupBy(p => p.Manifest.ContentPackFor.UniqueID, StringComparer.InvariantCultureIgnoreCase)
+ .ToDictionary(
+ group => group.Key,
+ group => group.Select(metadata => metadata.ContentPack).ToArray(),
+ StringComparer.InvariantCultureIgnoreCase
+ );
+
+ // load mods from metadata
+ using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor))
+ {
+ InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory();
+ foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack))
+ {
+ // get basic info
+ IManifest manifest = metadata.Manifest;
+ this.Monitor.Log(metadata.Manifest?.EntryDll != null
+ ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid
+ : $" {metadata.DisplayName}...", LogLevel.Trace);
+
+ // show warnings
+ if (metadata.HasManifest() && !metadata.HasUpdateKeys() && !suppressUpdateChecks.Contains(metadata.Manifest.UniqueID))
+ metadata.SetWarning(ModWarning.NoUpdateKeys);
+
+ // validate status
+ if (metadata.Status == ModMetadataStatus.Failed)
+ {
+ this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
+ TrackSkip(metadata, metadata.Error);
+ continue;
+ }
+
+ // load mod
+ string assemblyPath = metadata.Manifest?.EntryDll != null
+ ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll)
+ : null;
+ Assembly modAssembly;
+ try
+ {
+ modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible);
+ }
+ catch (IncompatibleInstructionException) // details already in trace logs
+ {
+ string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray();
+
+ TrackSkip(metadata, $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}.");
+ continue;
+ }
+ catch (SAssemblyLoadFailedException ex)
+ {
+ TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}");
+ continue;
+ }
+ catch (Exception ex)
+ {
+ TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}");
+ continue;
+ }
+
+ // initialise mod
+ try
+ {
+ // get mod instance
+ if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod))
+ continue;
+
+ // get content packs
+ if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks))
+ contentPacks = new IContentPack[0];
+
+ // init mod helpers
+ IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
+ IModHelper modHelper;
+ {
+ IModEvents events = new ModEvents(metadata, this.EventManager);
+ ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.GameInstance.CommandManager);
+ IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
+ IDataHelper dataHelper = new DataHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper);
+ IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager);
+ 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 CreateTransitionalContentPack(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, metadata.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager);
+ }
+
+ // init mod
+ mod.ModManifest = manifest;
+ mod.Helper = modHelper;
+ mod.Monitor = monitor;
+
+ // track mod
+ metadata.SetMod(mod);
+ this.ModRegistry.Add(metadata);
+ }
+ catch (Exception ex)
+ {
+ TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}");
+ }
+ }
+ }
+ }
+ IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray();
+
+ // 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 => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.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<IAssetEditor>().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<IAssetLoader>().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;
+ }
+
+ /// <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(IModMetadata[] mods, IDictionary<IModMetadata, 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())
+ {
+ 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();
+
+ foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
+ {
+ IModMetadata mod = pair.Key;
+ string[] reason = pair.Value;
+
+ this.Monitor.Log($" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {reason[0]}", LogLevel.Error);
+ if (reason[1] != null)
+ this.Monitor.Log($" ({reason[1]})", 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."
+ );
+ 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."
+ );
+ }
+ }
+
+ /// <summary>Load a mod's entry class.</summary>
+ /// <param name="modAssembly">The mod assembly.</param>
+ /// <param name="onError">A callback invoked when loading fails.</param>
+ /// <param name="mod">The loaded instance.</param>
+ private bool TryLoadModEntry(Assembly modAssembly, Action<string> onError, out Mod mod)
+ {
+ mod = null;
+
+ // find type
+ TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray();
+ if (modEntries.Length == 0)
+ {
+ onError($"its DLL has no '{nameof(Mod)}' subclass.");
+ return false;
+ }
+ if (modEntries.Length > 1)
+ {
+ onError($"its DLL contains multiple '{nameof(Mod)}' subclasses.");
+ return false;
+ }
+
+ // get implementation
+ mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString());
+ if (mod == null)
+ {
+ onError("its entry class couldn't be instantiated.");
+ return false;
+ }
+
+ return true;
+ }
+
+ /// <summary>Reload translations for all mods.</summary>
+ /// <param name="mods">The mods for which to reload translations.</param>
+ private void ReloadTranslations(IEnumerable<IModMetadata> 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<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>();
+ 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<string, string> data))
+ translations[locale] = data;
+ else
+ metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.");
+ }
+ catch (Exception ex)
+ {
+ metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}");
+ }
+ }
+ }
+
+ // 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<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ HashSet<string> duplicateKeys = new HashSet<string>(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);
+ }
+ }
+
+ /// <summary>The method called when the user submits a core SMAPI command in the console.</summary>
+ /// <param name="name">The command name.</param>
+ /// <param name="arguments">The command arguments.</param>
+ 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}\n(Added by {result.ModName}.)", LogLevel.Info);
+ }
+ else
+ {
+ string message = "The following commands are registered:\n";
+ IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray();
+ foreach (var group in groups)
+ {
+ string modName = group.Key;
+ 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}'.");
+ }
+ }
+
+ /// <summary>Redirect messages logged directly to the console to the given monitor.</summary>
+ /// <param name="monitor">The monitor with which to log messages.</param>
+ /// <param name="message">The message to log.</param>
+ private void HandleConsoleMessage(IMonitor monitor, 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;
+
+ // forward to monitor
+ monitor.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.CancellationTokenSource, this.Settings.ColorScheme)
+ {
+ WriteToConsole = this.Monitor.WriteToConsole,
+ ShowTraceInConsole = this.Settings.DeveloperMode,
+ ShowFullStampInConsole = this.Settings.DeveloperMode
+ };
+ }
+
+ /// <summary>Log a message if verbose mode is enabled.</summary>
+ /// <param name="message">The message to log.</param>
+ private void VerboseLog(string message)
+ {
+ if (this.Settings.VerboseLogging)
+ this.Monitor.Log(message, LogLevel.Trace);
+ }
+
+ /// <summary>Get the absolute path to the next available log file.</summary>
+ 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.");
+ }
+
+ /// <summary>Delete all log files created by SMAPI.</summary>
+ private void PurgeLogFiles()
+ {
+ DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir);
+ if (!logsDir.Exists)
+ return;
+
+ foreach (FileInfo logFile in logsDir.EnumerateFiles())
+ {
+ if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase))
+ {
+ try
+ {
+ FileUtilities.ForceDelete(logFile);
+ }
+ catch (IOException)
+ {
+ // ignore file if it's in use
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index d34cbab5..c6f39e3f 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -1,46 +1,17 @@
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;
-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.Toolkit;
-using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
-using StardewModdingAPI.Toolkit.Framework.ModData;
-using StardewModdingAPI.Toolkit.Serialisation;
-using StardewModdingAPI.Toolkit.Utilities;
-using StardewValley;
-using Monitor = StardewModdingAPI.Framework.Monitor;
-using SObject = StardewValley.Object;
-using ThreadState = System.Threading.ThreadState;
namespace StardewModdingAPI
{
/// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary>
- internal class Program : IDisposable
+ internal class Program
{
/*********
** Properties
@@ -50,64 +21,6 @@ namespace StardewModdingAPI
[SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")]
internal static readonly string DllSearchPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "smapi-internal");
- /// <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>The core logger and monitor for SMAPI.</summary>
- private readonly Monitor Monitor;
-
- /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary>
- private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource();
-
- /// <summary>Simplifies access to private game code.</summary>
- private readonly Reflector Reflection = new Reflector();
-
- /// <summary>The SMAPI configuration settings.</summary>
- private readonly SConfig Settings;
-
- /// <summary>The underlying game instance.</summary>
- private SGame GameInstance;
-
- /// <summary>The underlying content manager.</summary>
- private ContentCoordinator ContentCore => this.GameInstance.ContentCore;
-
- /// <summary>Tracks the installed mods.</summary>
- /// <remarks>This is initialised after the game starts.</remarks>
- private readonly ModRegistry ModRegistry = new ModRegistry();
-
- /// <summary>Manages deprecation warnings.</summary>
- /// <remarks>This is initialised after the game starts.</remarks>
- private DeprecationManager DeprecationManager;
-
- /// <summary>Manages SMAPI events for mods.</summary>
- private readonly EventManager EventManager;
-
- /// <summary>Whether the game is currently running.</summary>
- private bool IsGameRunning;
-
- /// <summary>Whether the program has been disposed.</summary>
- private bool IsDisposed;
-
- /// <summary>Regex patterns which match console 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: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant),
- new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
- };
-
- /// <summary>The mod toolkit used for generic mod interactions.</summary>
- private readonly ModToolkit Toolkit = new ModToolkit();
-
- /// <summary>The path to search for mods.</summary>
- private readonly string ModsPath;
-
/*********
** Public methods
@@ -116,255 +29,15 @@ namespace StardewModdingAPI
/// <param name="args">The command-line arguments.</param>
public static void Main(string[] args)
{
- // initial setup
AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve;
Program.AssertMinimumCompatibility();
-
- // get flags from arguments
- bool writeToConsole = !args.Contains("--no-terminal");
-
- // get mods path from arguments
- string modsPath = null;
- {
- int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1;
- if (pathIndex >= 1 && args.Length >= pathIndex)
- {
- modsPath = args[pathIndex];
- if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath))
- modsPath = Path.Combine(Constants.ExecutionPath, modsPath);
- }
- if (string.IsNullOrWhiteSpace(modsPath))
- modsPath = Constants.DefaultModsPath;
- }
-
- // load SMAPI
- using (Program program = new Program(modsPath, writeToConsole))
- program.RunInteractively();
- }
-
- /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
- 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();
+ Program.Start(args);
}
/*********
** Private methods
*********/
- /// <summary>Construct an instance.</summary>
- /// <param name="modsPath">The path to search for mods.</param>
- /// <param name="writeToConsole">Whether to output log messages to the console.</param>
- private Program(string modsPath, bool writeToConsole)
- {
- // init paths
- this.VerifyPath(modsPath);
- this.VerifyPath(Constants.LogDir);
- this.ModsPath = modsPath;
-
- // init log file
- this.PurgeLogFiles();
- string logPath = this.GetLogPath();
-
- // init basics
- this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
- this.LogFile = new LogFileManager(logPath);
- this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme)
- {
- WriteToConsole = writeToConsole,
- ShowTraceInConsole = this.Settings.DeveloperMode,
- ShowFullStampInConsole = this.Settings.DeveloperMode
- };
- this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
-
- // 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 game version
- if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion))
- {
- this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error);
- this.PressAnyKeyToExit();
- return;
- }
- if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion))
- {
- this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error);
- this.PressAnyKeyToExit();
- return;
- }
-
- // apply game patches
- new GamePatcher(this.Monitor).Apply(
- // new GameLocationPatch()
- );
- }
-
- /// <summary>Launch SMAPI.</summary>
- [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions
- private void RunInteractively()
- {
- // initialise SMAPI
- try
- {
- // 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);
-
- // 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.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose);
- StardewValley.Program.gamePtr = this.GameInstance;
-
- // 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();
-
- // hook into game events
- ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged();
-
- // 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}";
- }
- 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: http://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 (Exception ex)
- {
- this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error);
- this.PressAnyKeyToExit();
- }
- finally
- {
- this.Dispose();
- }
- }
-
/// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
@@ -422,880 +95,31 @@ namespace StardewModdingAPI
}
}
- /// <summary>Initialise SMAPI and mods after the game starts.</summary>
- private void InitialiseAfterGameStart()
- {
- // load settings
- this.GameInstance.VerboseLogging = this.Settings.VerboseLogging;
-
- // load core components
- this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
-
- // redirect direct console output
- {
- Monitor monitor = this.GetSecondaryMonitor("game");
- if (monitor.WriteToConsole)
- this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message);
- }
-
- // add headers
- if (this.Settings.DeveloperMode)
- this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may 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.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();
- resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl);
-
- // process dependencies
- mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
-
- // load mods
- 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";
-
- // start SMAPI console
- new Thread(this.RunConsoleLoop).Start();
- }
-
- /// <summary>Handle the game changing locale.</summary>
- 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);
- }
-
- /// <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("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand);
- this.GameInstance.CommandManager.Add("SMAPI", "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();
- }
-
- /// <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);
- 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<int, string> 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 < SObject.objectInfoDescriptionIndex + 1)
- {
- LogIssue(entry.Key, "too few fields for an object");
- hasObjectIssues = true;
- continue;
- }
-
- // check min length for specific types
- switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0])
- {
- case "Cooking":
- if (fields.Length < SObject.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;
- }
-
- /// <summary>Asynchronously check for a new version of SMAPI and any installed mods, and print alerts to the console if an update is available.</summary>
- /// <param name="mods">The mods to include in the update check (if eligible).</param>
- 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<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
-
- // prepare search model
- List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>();
- foreach (IModMetadata mod in mods)
- {
- if (!mod.HasID())
- continue;
-
- string[] updateKeys = mod.Manifest.UpdateKeys ?? new string[0];
- searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.Except(suppressUpdateChecks).ToArray()));
- }
-
- // fetch results
- this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace);
- IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray());
-
- // extract update alerts & errors
- var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>();
- 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
- 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 = 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, 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();
- }
-
- /// <summary>Get whether a given version should be offered to the user as an update.</summary>
- /// <param name="currentVersion">The current semantic version.</param>
- /// <param name="newVersion">The target semantic version.</param>
- /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param>
- private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel)
- {
- return
- newVersion != null
- && newVersion.IsNewerThan(currentVersion)
- && (useBetaChannel || !newVersion.IsPrerelease());
- }
-
- /// <summary>Create a directory path if it doesn't exist.</summary>
- /// <param name="path">The directory path.</param>
- private void VerifyPath(string path)
- {
- try
- {
- if (!Directory.Exists(path))
- Directory.CreateDirectory(path);
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error);
- }
- }
-
- /// <summary>Load and hook up the given mods.</summary>
- /// <param name="mods">The mods to load.</param>
- /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param>
- /// <param name="contentCore">The content manager to use for mod content.</param>
- /// <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);
-
- HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase);
- IDictionary<IModMetadata, string[]> skippedMods = new Dictionary<IModMetadata, string[]>();
- void TrackSkip(IModMetadata mod, string userReasonPhrase, string devReasonPhrase = null) => skippedMods[mod] = new[] { userReasonPhrase, devReasonPhrase };
-
- // load content packs
- foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack))
- {
- this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)})...", LogLevel.Trace);
-
- // show warning for missing update key
- if (metadata.HasManifest() && !metadata.HasUpdateKeys())
- metadata.SetWarning(ModWarning.NoUpdateKeys);
-
- // validate status
- if (metadata.Status == ModMetadataStatus.Failed)
- {
- this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
- TrackSkip(metadata, metadata.Error);
- continue;
- }
-
- // load mod as content pack
- IManifest manifest = metadata.Manifest;
- IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
- IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
- IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper);
- metadata.SetMod(contentPack, monitor);
- this.ModRegistry.Add(metadata);
- }
- IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray();
-
- // load mods
- {
- // get content packs by mod ID
- IDictionary<string, IContentPack[]> contentPacksByModID =
- loadedContentPacks
- .GroupBy(p => p.Manifest.ContentPackFor.UniqueID, StringComparer.InvariantCultureIgnoreCase)
- .ToDictionary(
- group => group.Key,
- group => group.Select(metadata => metadata.ContentPack).ToArray(),
- StringComparer.InvariantCultureIgnoreCase
- );
-
- // load mods from metadata
- using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor))
- {
- InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory();
- foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack))
- {
- // get basic info
- IManifest manifest = metadata.Manifest;
- this.Monitor.Log(metadata.Manifest?.EntryDll != null
- ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid
- : $" {metadata.DisplayName}...", LogLevel.Trace);
-
- // show warnings
- if (metadata.HasManifest() && !metadata.HasUpdateKeys() && !suppressUpdateChecks.Contains(metadata.Manifest.UniqueID))
- metadata.SetWarning(ModWarning.NoUpdateKeys);
-
- // validate status
- if (metadata.Status == ModMetadataStatus.Failed)
- {
- this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace);
- TrackSkip(metadata, metadata.Error);
- continue;
- }
-
- // load mod
- string assemblyPath = metadata.Manifest?.EntryDll != null
- ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll)
- : null;
- Assembly modAssembly;
- try
- {
- modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible);
- }
- catch (IncompatibleInstructionException) // details already in trace logs
- {
- string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray();
-
- TrackSkip(metadata, $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}.");
- continue;
- }
- catch (SAssemblyLoadFailedException ex)
- {
- TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}");
- continue;
- }
- catch (Exception ex)
- {
- TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}");
- continue;
- }
-
- // initialise mod
- try
- {
- // get mod instance
- if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod))
- continue;
-
- // get content packs
- if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks))
- contentPacks = new IContentPack[0];
-
- // init mod helpers
- IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName);
- IModHelper modHelper;
- {
- IModEvents events = new ModEvents(metadata, this.EventManager);
- ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.GameInstance.CommandManager);
- IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor);
- IDataHelper dataHelper = new DataHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper);
- IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager);
- 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 CreateTransitionalContentPack(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, metadata.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager);
- }
-
- // init mod
- mod.ModManifest = manifest;
- mod.Helper = modHelper;
- mod.Monitor = monitor;
-
- // track mod
- metadata.SetMod(mod);
- this.ModRegistry.Add(metadata);
- }
- catch (Exception ex)
- {
- TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}");
- }
- }
- }
- }
- IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray();
-
- // 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 => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.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<IAssetEditor>().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<IAssetLoader>().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;
- }
-
- /// <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(IModMetadata[] mods, IDictionary<IModMetadata, 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())
- {
- 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();
-
- foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
- {
- IModMetadata mod = pair.Key;
- string[] reason = pair.Value;
-
- this.Monitor.Log($" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {reason[0]}", LogLevel.Error);
- if (reason[1] != null)
- this.Monitor.Log($" ({reason[1]})", 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."
- );
- 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."
- );
- }
- }
-
- /// <summary>Load a mod's entry class.</summary>
- /// <param name="modAssembly">The mod assembly.</param>
- /// <param name="onError">A callback invoked when loading fails.</param>
- /// <param name="mod">The loaded instance.</param>
- private bool TryLoadModEntry(Assembly modAssembly, Action<string> onError, out Mod mod)
+ /// <summary>Initialise SMAPI and launch the game.</summary>
+ /// <param name="args">The command-line arguments.</param>
+ /// <remarks>This method is separate from <see cref="Main"/> because that can't contain any references to assemblies loaded by <see cref="CurrentDomain_AssemblyResolve"/> (e.g. via <see cref="Constants"/>), or Mono will incorrectly show an assembly resolution error before assembly resolution is set up.</remarks>
+ private static void Start(string[] args)
{
- mod = null;
-
- // find type
- TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray();
- if (modEntries.Length == 0)
- {
- onError($"its DLL has no '{nameof(Mod)}' subclass.");
- return false;
- }
- if (modEntries.Length > 1)
- {
- onError($"its DLL contains multiple '{nameof(Mod)}' subclasses.");
- return false;
- }
-
- // get implementation
- mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString());
- if (mod == null)
- {
- onError("its entry class couldn't be instantiated.");
- return false;
- }
-
- return true;
- }
+ // get flags from arguments
+ bool writeToConsole = !args.Contains("--no-terminal");
- /// <summary>Reload translations for all mods.</summary>
- /// <param name="mods">The mods for which to reload translations.</param>
- private void ReloadTranslations(IEnumerable<IModMetadata> mods)
- {
- JsonHelper jsonHelper = this.Toolkit.JsonHelper;
- foreach (IModMetadata metadata in mods)
+ // get mods path from arguments
+ string modsPath = null;
{
- if (metadata.IsContentPack)
- throw new InvalidOperationException("Can't reload translations for a content pack.");
-
- // read translation files
- IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>();
- 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<string, string> data))
- translations[locale] = data;
- else
- metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.");
- }
- catch (Exception ex)
- {
- metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}");
- }
- }
- }
-
- // validate translations
- foreach (string locale in translations.Keys.ToArray())
+ int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1;
+ if (pathIndex >= 1 && args.Length >= pathIndex)
{
- // 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<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
- HashSet<string> duplicateKeys = new HashSet<string>(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);
+ modsPath = args[pathIndex];
+ if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath))
+ modsPath = Path.Combine(Constants.ExecutionPath, modsPath);
}
-
- // update translation
- TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation;
- translationHelper.SetTranslations(translations);
- }
- }
-
- /// <summary>The method called when the user submits a core SMAPI command in the console.</summary>
- /// <param name="name">The command name.</param>
- /// <param name="arguments">The command arguments.</param>
- 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}\n(Added by {result.ModName}.)", LogLevel.Info);
- }
- else
- {
- string message = "The following commands are registered:\n";
- IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray();
- foreach (var group in groups)
- {
- string modName = group.Key;
- 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}'.");
+ if (string.IsNullOrWhiteSpace(modsPath))
+ modsPath = Constants.DefaultModsPath;
}
- }
- /// <summary>Redirect messages logged directly to the console to the given monitor.</summary>
- /// <param name="monitor">The monitor with which to log messages.</param>
- /// <param name="message">The message to log.</param>
- private void HandleConsoleMessage(IMonitor monitor, 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;
-
- // forward to monitor
- monitor.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);
- Program.PressAnyKeyToExit(showMessage: false);
+ // load SMAPI
+ using (SCore core = new SCore(modsPath, writeToConsole))
+ core.RunInteractively();
}
/// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary>
@@ -1308,70 +132,5 @@ namespace StardewModdingAPI
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.CancellationTokenSource, this.Settings.ColorScheme)
- {
- WriteToConsole = this.Monitor.WriteToConsole,
- ShowTraceInConsole = this.Settings.DeveloperMode,
- ShowFullStampInConsole = this.Settings.DeveloperMode
- };
- }
-
- /// <summary>Log a message if verbose mode is enabled.</summary>
- /// <param name="message">The message to log.</param>
- private void VerboseLog(string message)
- {
- if (this.Settings.VerboseLogging)
- this.Monitor.Log(message, LogLevel.Trace);
- }
-
- /// <summary>Get the absolute path to the next available log file.</summary>
- 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.");
- }
-
- /// <summary>Delete all log files created by SMAPI.</summary>
- private void PurgeLogFiles()
- {
- DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir);
- if (!logsDir.Exists)
- return;
-
- foreach (FileInfo logFile in logsDir.EnumerateFiles())
- {
- if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase))
- {
- try
- {
- FileUtilities.ForceDelete(logFile);
- }
- catch (IOException)
- {
- // ignore file if it's in use
- }
- }
- }
- }
}
}
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index 740af15f..2602da27 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -110,6 +110,7 @@
<Compile Include="Events\WorldLocationListChangedEventArgs.cs" />
<Compile Include="Events\WorldObjectListChangedEventArgs.cs" />
<Compile Include="Framework\ModHelpers\DataHelper.cs" />
+ <Compile Include="Framework\SCore.cs" />
<Compile Include="Framework\SGameConstructorHack.cs" />
<Compile Include="Framework\ContentManagers\BaseContentManager.cs" />
<Compile Include="Framework\ContentManagers\GameContentManager.cs" />