diff options
Diffstat (limited to 'src/StardewModdingAPI/Program.cs')
-rw-r--r-- | src/StardewModdingAPI/Program.cs | 371 |
1 files changed, 191 insertions, 180 deletions
diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 58850dc3..31aeb3a6 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -10,7 +9,6 @@ using System.Threading; using System.Management; using System.Windows.Forms; #endif -using Microsoft.Xna.Framework.Graphics; using Newtonsoft.Json; using StardewModdingAPI.AssemblyRewriters; using StardewModdingAPI.Events; @@ -18,13 +16,12 @@ using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.Serialisation; -using StardewValley; using Monitor = StardewModdingAPI.Framework.Monitor; namespace StardewModdingAPI { /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> - internal class Program + internal class Program : IDisposable { /********* ** Properties @@ -38,26 +35,33 @@ namespace StardewModdingAPI /// <summary>The core logger for SMAPI.</summary> private readonly Monitor Monitor; - /// <summary>The SMAPI configuration settings.</summary> - private readonly SConfig Settings; - /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); - /// <summary>Whether the game is currently running.</summary> - private bool IsGameRunning; - /// <summary>The underlying game instance.</summary> private SGame GameInstance; + /// <summary>The SMAPI configuration settings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private SConfig Settings; + /// <summary>Tracks the installed mods.</summary> - private readonly ModRegistry ModRegistry; + /// <remarks>This is initialised after the game starts.</remarks> + private ModRegistry ModRegistry; /// <summary>Manages deprecation warnings.</summary> - private readonly DeprecationManager DeprecationManager; + /// <remarks>This is initialised after the game starts.</remarks> + private DeprecationManager DeprecationManager; /// <summary>Manages console commands.</summary> - private readonly CommandManager CommandManager = new CommandManager(); + /// <remarks>This is initialised after the game starts.</remarks> + private CommandManager CommandManager; + + /// <summary>Whether the game is currently running.</summary> + private bool IsGameRunning; + + /// <summary>Whether the program has been disposed.</summary> + private bool IsDisposed; /********* @@ -65,7 +69,7 @@ namespace StardewModdingAPI *********/ /// <summary>The main entry point which hooks into and launches the game.</summary> /// <param name="args">The command-line arguments.</param> - private static void Main(string[] args) + public static void Main(string[] args) { // get flags from arguments bool writeToConsole = !args.Contains("--no-terminal"); @@ -85,71 +89,35 @@ namespace StardewModdingAPI logPath = Constants.DefaultLogPath; // load SMAPI - new Program(writeToConsole, logPath) - .LaunchInteractively(); + using (Program program = new Program(writeToConsole, logPath)) + program.RunInteractively(); } /// <summary>Construct an instance.</summary> /// <param name="writeToConsole">Whether to output log messages to the console.</param> /// <param name="logPath">The full file path to which to write log messages.</param> - internal Program(bool writeToConsole, string logPath) + public Program(bool writeToConsole, string logPath) { - // load settings - this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); - - // initialise this.LogFile = new LogFileManager(logPath); this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.ExitGameImmediately) { WriteToConsole = writeToConsole }; - this.ModRegistry = new ModRegistry(this.Settings.ModCompatibility); - this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); } /// <summary>Launch SMAPI.</summary> - internal void LaunchInteractively() + public void RunInteractively() { - // initialise logging - Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB"); // for consistent log formatting - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} on {this.GetFriendlyPlatformName()}", LogLevel.Info); - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}"; - - // inject compatibility shims -#pragma warning disable 618 - Command.Shim(this.CommandManager, this.DeprecationManager, this.ModRegistry); - Config.Shim(this.DeprecationManager); - InternalExtensions.Shim(this.ModRegistry); - Log.Shim(this.DeprecationManager, this.GetSecondaryMonitor("legacy mod"), this.ModRegistry); - Mod.Shim(this.DeprecationManager); - ContentEvents.Shim(this.ModRegistry, this.Monitor); - PlayerEvents.Shim(this.DeprecationManager); - TimeEvents.Shim(this.DeprecationManager); -#pragma warning restore 618 - - // redirect direct console output - { - Monitor monitor = this.GetSecondaryMonitor("Console.Out"); - monitor.WriteToFile = false; // not useful for troubleshooting mods per discussion - if (monitor.WriteToConsole) - this.ConsoleManager.OnLineIntercepted += line => monitor.Log(line, LogLevel.Trace); - } - - // add warning headers - if (this.Settings.DeveloperMode) + // initialise SMAPI + try { - this.Monitor.ShowTraceInConsole = true; - 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.Warn); - } - 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); + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} on {this.GetFriendlyPlatformName()}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + this.Monitor.Log("Preparing SMAPI..."); - // print file paths - this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + // validate paths + this.VerifyPath(Constants.ModPath); + this.VerifyPath(Constants.LogDir); - // hook into & launch the game - try - { - // verify version + // validate game version if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) { this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}, but the oldest supported version is {Constants.GetGameDisplayVersion(Constants.MinimumGameVersion)}. Please update your game before using SMAPI. If you have the beta version on Steam, you may need to opt out to get the latest non-beta updates.", LogLevel.Error); @@ -163,29 +131,59 @@ namespace StardewModdingAPI return; } - // initialise folders - this.Monitor.Log("Loading SMAPI..."); - this.VerifyPath(Constants.ModPath); - this.VerifyPath(Constants.LogDir); + // 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); + + // override game + this.GameInstance = new SGame(this.Monitor); + StardewValley.Program.gamePtr = this.GameInstance; + + // hook into game events +#if SMAPI_FOR_WINDOWS + ((Form)Control.FromHandle(this.GameInstance.Window.Handle)).FormClosing += (sender, args) => this.Dispose(); +#endif + this.GameInstance.Exiting += (sender, e) => this.Dispose(); + this.GameInstance.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(this.Monitor, sender, e); + GameEvents.InitializeInternal += (sender, e) => this.InitialiseAfterGameStart(); + GameEvents.GameLoaded += (sender, e) => this.CheckForUpdateAsync(); - // check for update when game loads - if (this.Settings.CheckForUpdates) - GameEvents.GameLoaded += (sender, e) => this.CheckForUpdateAsync(); + // set window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}"; + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } - // launch game - this.StartGame(); + // start game + this.Monitor.Log("Starting game..."); + try + { + this.IsGameRunning = true; + this.GameInstance.Run(); } catch (Exception ex) { - this.Monitor.Log($"Critical error: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + } + finally + { + this.Dispose(); } - this.PressAnyKeyToExit(); } /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> /// <param name="module">The module which requested an immediate exit.</param> /// <param name="reason">The reason provided for the shutdown.</param> - internal void ExitGameImmediately(string module, string reason) + public void ExitGameImmediately(string module, string reason) { this.Monitor.LogFatal($"{module} requested an immediate game shutdown: {reason}"); this.CancellationTokenSource.Cancel(); @@ -204,13 +202,124 @@ namespace StardewModdingAPI return this.GetSecondaryMonitor(modName); } + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + if (this.IsDisposed) + return; + this.IsDisposed = true; + + this.IsGameRunning = false; + this.LogFile?.Dispose(); + this.ConsoleManager?.Dispose(); + this.CancellationTokenSource?.Dispose(); + this.GameInstance?.Dispose(); + } + /********* ** Private methods *********/ + /// <summary>Initialise SMAPI and mods after the game starts.</summary> + private void InitialiseAfterGameStart() + { + // load settings + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + + // load core components + this.ModRegistry = new ModRegistry(this.Settings.ModCompatibility); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + this.CommandManager = new CommandManager(); + + // inject compatibility shims +#pragma warning disable 618 + Command.Shim(this.CommandManager, this.DeprecationManager, this.ModRegistry); + Config.Shim(this.DeprecationManager); + InternalExtensions.Shim(this.ModRegistry); + Log.Shim(this.DeprecationManager, this.GetSecondaryMonitor("legacy mod"), this.ModRegistry); + Mod.Shim(this.DeprecationManager); + ContentEvents.Shim(this.ModRegistry, this.Monitor); + GameEvents.Shim(this.DeprecationManager); + PlayerEvents.Shim(this.DeprecationManager); + TimeEvents.Shim(this.DeprecationManager); +#pragma warning restore 618 + + // redirect direct console output + { + Monitor monitor = this.GetSecondaryMonitor("Console.Out"); + monitor.WriteToFile = false; // not useful for troubleshooting mods per discussion + if (monitor.WriteToConsole) + this.ConsoleManager.OnLineIntercepted += line => monitor.Log(line, LogLevel.Trace); + } + + // add warning headers + if (this.Settings.DeveloperMode) + { + this.Monitor.ShowTraceInConsole = true; + 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.Warn); + } + 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); + + // load mods + int modsLoaded = this.LoadMods(); + if (this.CancellationTokenSource.IsCancellationRequested) + { + this.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); + return; + } + + // update window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} with {modsLoaded} mods"; + + // start SMAPI console + new Thread(this.RunConsoleLoop).Start(); + } + + /// <summary>Run a loop handling console input.</summary> + [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] + private void RunConsoleLoop() + { + // prepare help command + this.Monitor.Log("Starting console..."); + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + this.CommandManager.Add("SMAPI", "help", "Lists all commands | 'help <cmd>' returns command description", this.HandleHelpCommand); + + // start handling command line input + Thread inputThread = new Thread(() => + { + while (true) + { + string input = Console.ReadLine(); + try + { + if (!string.IsNullOrWhiteSpace(input) && !this.CommandManager.Trigger(input)) + this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + } + catch (Exception ex) + { + this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + }); + inputThread.Start(); + + // keep console thread alive while the game is running + while (this.IsGameRunning) + Thread.Sleep(1000 / 10); + if (inputThread.ThreadState == ThreadState.Running) + inputThread.Abort(); + } + /// <summary>Asynchronously check for a new version of SMAPI, and print a message to the console if an update is available.</summary> private void CheckForUpdateAsync() { + if (!this.Settings.CheckForUpdates) + return; + new Thread(() => { try @@ -227,87 +336,6 @@ namespace StardewModdingAPI }).Start(); } - /// <summary>Hook into Stardew Valley and launch the game.</summary> - private void StartGame() - { - try - { - this.Monitor.Log("Loading game..."); - - // 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); - - // override Game1 instance - this.GameInstance = new SGame(this.Monitor); - this.GameInstance.Exiting += (sender, e) => this.IsGameRunning = false; - this.GameInstance.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(this.Monitor, sender, e); - this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} with SMAPI {Constants.ApiVersion}"; - { - Type type = typeof(Game1).Assembly.GetType("StardewValley.Program", true); - type.GetField("gamePtr").SetValue(null, this.GameInstance); - } - - // configure - Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; - - // load mods - this.LoadMods(); - if (this.CancellationTokenSource.IsCancellationRequested) - { - this.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); - return; - } - - // initialise console after game launches - new Thread(() => - { - // wait for the game to load up - while (!this.IsGameRunning) - Thread.Sleep(1000); - - // register help command - this.CommandManager.Add("SMAPI", "help", "Lists all commands | 'help <cmd>' returns command description", this.HandleHelpCommand); - - // listen for command line input - this.Monitor.Log("Starting console..."); - this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - Thread consoleInputThread = new Thread(this.ConsoleInputLoop); - consoleInputThread.Start(); - while (this.IsGameRunning) - Thread.Sleep(1000 / 10); // Check if the game is still running 10 times a second - - // abort the console thread, we're closing - if (consoleInputThread.ThreadState == ThreadState.Running) - consoleInputThread.Abort(); - }).Start(); - - // start game loop - this.Monitor.Log("Starting game..."); - if (this.CancellationTokenSource.IsCancellationRequested) - { - this.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error); - return; - } - try - { - this.IsGameRunning = true; - this.GameInstance.Run(); - } - finally - { - this.IsGameRunning = false; - } - } - catch (Exception ex) - { - this.Monitor.Log($"The game encountered a fatal error:\n{ex.GetLogSummary()}", LogLevel.Error); - } - } - /// <summary>Create a directory path if it doesn't exist.</summary> /// <param name="path">The directory path.</param> private void VerifyPath(string path) @@ -324,7 +352,8 @@ namespace StardewModdingAPI } /// <summary>Load and hook up all mods in the mod directory.</summary> - private void LoadMods() + /// <returns>Returns the number of mods loaded.</returns> + private int LoadMods() { this.Monitor.Log("Loading mods..."); @@ -349,7 +378,7 @@ namespace StardewModdingAPI if (this.CancellationTokenSource.IsCancellationRequested) { this.Monitor.Log("Shutdown requested; interrupting mod loading.", LogLevel.Error); - return; + return modsLoaded; } // get manifest path @@ -529,12 +558,12 @@ namespace StardewModdingAPI } // initialise mods - foreach (Mod mod in this.ModRegistry.GetMods()) + foreach (IMod mod in this.ModRegistry.GetMods()) { try { // call entry methods - mod.Entry(); // deprecated since 1.0 + (mod as Mod)?.Entry(); // deprecated since 1.0 mod.Entry(mod.Helper); // raise deprecation warning for old Entry() methods @@ -551,26 +580,8 @@ namespace StardewModdingAPI this.Monitor.Log($"Loaded {modsLoaded} mods."); foreach (Action warning in deprecationWarnings) warning(); - Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} with {modsLoaded} mods"; - } - /// <summary>Run a loop handling console input.</summary> - [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] - private void ConsoleInputLoop() - { - while (true) - { - string input = Console.ReadLine(); - try - { - if (!string.IsNullOrWhiteSpace(input) && !this.CommandManager.Trigger(input)) - this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); - } - catch (Exception ex) - { - this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); - } - } + return modsLoaded; } /// <summary>The method called when the user submits the help command in the console.</summary> |