diff options
Diffstat (limited to 'src/StardewModdingAPI/Program.cs')
-rw-r--r-- | src/StardewModdingAPI/Program.cs | 987 |
1 files changed, 987 insertions, 0 deletions
diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs new file mode 100644 index 00000000..84af2777 --- /dev/null +++ b/src/StardewModdingAPI/Program.cs @@ -0,0 +1,987 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Security; +using System.Threading; +#if SMAPI_FOR_WINDOWS +using System.Management; +using System.Windows.Forms; +#endif +using Newtonsoft.Json; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Logging; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModHelpers; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; +using StardewValley; +using Monitor = StardewModdingAPI.Framework.Monitor; +using SObject = StardewValley.Object; + +namespace StardewModdingAPI +{ + /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> + internal class Program : 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 underlying game instance.</summary> + private SGame GameInstance; + + /// <summary>The underlying content manager.</summary> + private SContentManager ContentManager => this.GameInstance.SContentManager; + + /// <summary>The SMAPI configuration settings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private SConfig Settings; + + /// <summary>Tracks the installed mods.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private ModRegistry ModRegistry; + + /// <summary>Manages deprecation warnings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private DeprecationManager DeprecationManager; + + /// <summary>Manages console commands.</summary> + /// <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; + + + /********* + ** Public methods + *********/ + /// <summary>The main entry point which hooks into and launches the game.</summary> + /// <param name="args">The command-line arguments.</param> + public static void Main(string[] args) + { + Program.AssertMinimumCompatibility(); + + // get flags from arguments + bool writeToConsole = !args.Contains("--no-terminal"); + + // get log path from arguments + string logPath = null; + { + int pathIndex = Array.LastIndexOf(args, "--log-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) + { + logPath = args[pathIndex]; + if (!Path.IsPathRooted(logPath)) + logPath = Path.Combine(Constants.LogDir, logPath); + } + } + if (string.IsNullOrWhiteSpace(logPath)) + logPath = Constants.DefaultLogPath; + + // load SMAPI + 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> + public Program(bool writeToConsole, string logPath) + { + this.LogFile = new LogFileManager(logPath); + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = writeToConsole }; + } + + /// <summary>Launch SMAPI.</summary> + [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions + public void RunInteractively() + { + // initialise SMAPI + try + { + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); +#if SMAPI_1_x + this.Monitor.Log("Preparing SMAPI..."); +#endif + + // validate paths + this.VerifyPath(Constants.ModPath); + this.VerifyPath(Constants.LogDir); + + // 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.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // 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, this.Reflection); + 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 +#if SMAPI_FOR_WINDOWS + ((Form)Control.FromHandle(this.GameInstance.Window.Handle)).FormClosing += (sender, args) => this.Dispose(); +#endif + this.GameInstance.Exiting += (sender, e) => this.Dispose(); + GameEvents.InitializeInternal += (sender, e) => this.InitialiseAfterGameStart(); + GameEvents.GameLoadedInternal += (sender, e) => this.CheckForUpdateAsync(); + 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; + } + + // 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 attach this file: {Constants.FatalCrashLog}", 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 +#if SMAPI_1_x + this.Monitor.Log("Starting game..."); +#else + this.Monitor.Log("Starting game...", LogLevel.Trace); +#endif + try + { + this.IsGameRunning = true; + this.GameInstance.Run(); + } + catch (Exception ex) + { + this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + } + finally + { + this.Dispose(); + } + } + +#if SMAPI_1_x + /// <summary>Get a monitor for legacy code which doesn't have one passed in.</summary> + [Obsolete("This method should only be used when needed for backwards compatibility.")] + internal IMonitor GetLegacyMonitorForMod() + { + string modName = this.ModRegistry.GetModFromStack() ?? "unknown"; + return this.GetSecondaryMonitor(modName); + } +#endif + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Monitor.Log("Disposing...", LogLevel.Trace); + + // skip if already disposed + if (this.IsDisposed) + return; + this.IsDisposed = true; + + // dispose mod data + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + { + try + { + (mod.Mod as IDisposable)?.Dispose(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {mod.DisplayName} mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); + } + } + + // dispose core components + this.IsGameRunning = false; + this.LogFile?.Dispose(); + this.ConsoleManager?.Dispose(); + this.CancellationTokenSource?.Dispose(); + this.GameInstance?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Assert that the minimum conditions are present to initialise SMAPI without type load exceptions.</summary> + private static void AssertMinimumCompatibility() + { + void PrintErrorAndExit(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(message); + Console.ResetColor(); + Program.PressAnyKeyToExit(showMessage: true); + } + + // get game assembly name + const string gameAssemblyName = +#if SMAPI_FOR_WINDOWS + "Stardew Valley"; +#else + "StardewValley"; +#endif + + // game not present + if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) + { + PrintErrorAndExit( + "Oops! SMAPI can't find the game. " + + (Assembly.GetCallingAssembly().Location?.Contains(Path.Combine("internal", "Windows")) == true || Assembly.GetCallingAssembly().Location?.Contains(Path.Combine("internal", "Mono")) == true + ? "It looks like you're running SMAPI from the download package, but you need to run the installed version instead. " + : "Make sure you're running StardewModdingAPI.exe in your game folder. " + ) + + "See the readme.txt file for details." + ); + } + + // Stardew Valley 1.2 types not present + if (Type.GetType($"StardewValley.LocalizedContentManager+LanguageCode, {gameAssemblyName}", throwOnError: false) == null) + { + PrintErrorAndExit(Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion) + ? $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI." + : "Oops! SMAPI doesn't seem to be compatible with your game. Make sure you're running the latest version of Stardew Valley and SMAPI." + ); + } + } + + /// <summary>Initialise SMAPI and mods after the game starts.</summary> + private void InitialiseAfterGameStart() + { + // load settings + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + this.GameInstance.VerboseLogging = this.Settings.VerboseLogging; + + // load core components + this.ModRegistry = new ModRegistry(); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + this.CommandManager = new CommandManager(); + +#if SMAPI_1_x + // inject compatibility shims +#pragma warning disable 618 + Command.Shim(this.CommandManager, this.DeprecationManager, this.ModRegistry); + Config.Shim(this.DeprecationManager); + Log.Shim(this.DeprecationManager, this.GetSecondaryMonitor("legacy mod"), this.ModRegistry); + Mod.Shim(this.DeprecationManager); + GameEvents.Shim(this.DeprecationManager); + PlayerEvents.Shim(this.DeprecationManager); + TimeEvents.Shim(this.DeprecationManager); +#pragma warning restore 618 +#endif + + // redirect direct console output + { + Monitor monitor = this.GetSecondaryMonitor("Console.Out"); + if (monitor.WriteToConsole) + this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message); + } + + // add headers + if (this.Settings.DeveloperMode) + { + this.Monitor.ShowTraceInConsole = true; +#if !SMAPI_1_x + this.Monitor.ShowFullStampInConsole = true; +#endif + 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); + if (this.Settings.VerboseLogging) + this.Monitor.Log("Verbose logging enabled.", LogLevel.Trace); + + // 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 mods + { +#if SMAPI_1_x + this.Monitor.Log("Loading mod metadata..."); +#else + this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); +#endif + ModResolver resolver = new ModResolver(); + + // load manifests + IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility, this.Settings.DisabledMods).ToArray(); + resolver.ValidateManifests(mods, Constants.ApiVersion); + + // check for deprecated metadata +#if SMAPI_1_x + IList<Action> deprecationWarnings = new List<Action>(); + foreach (IModMetadata mod in mods.Where(m => m.Status != ModMetadataStatus.Failed)) + { + // missing fields that will be required in SMAPI 2.0 + { + List<string> missingFields = new List<string>(3); + + if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) + deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} is missing some manifest fields ({string.Join(", ", missingFields)}) which will be required in an upcoming SMAPI version.", LogLevel.Warn)); + } + + // per-save directories + if ((mod.Manifest as Manifest)?.PerSaveConfigs == true) + { + deprecationWarnings.Add(() => this.DeprecationManager.Warn(mod.DisplayName, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.PendingRemoval)); + try + { + string psDir = Path.Combine(mod.DirectoryPath, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) + mod.SetStatus(ModMetadataStatus.Failed, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); + } + catch (Exception ex) + { + mod.SetStatus(ModMetadataStatus.Failed, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); + } + } + } +#endif + + // process dependencies + mods = resolver.ProcessDependencies(mods).ToArray(); + + // load mods +#if SMAPI_1_x + this.LoadMods(mods, new JsonHelper(), this.ContentManager, deprecationWarnings); + foreach (Action warning in deprecationWarnings) + warning(); +#else + this.LoadMods(mods, new JsonHelper(), this.ContentManager); +#endif + } + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); + return; + } + + // update window titles + int modsLoaded = this.ModRegistry.GetMods().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.ContentManager.GetLocale(); + LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); + + // update mod translation helpers + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + (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 +#if SMAPI_1_x + this.Monitor.Log("Starting console..."); +#endif + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + this.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.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; + + // parse input + try + { + if (!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 && !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 print a message to the console if an update is available.</summary> + private void CheckForUpdateAsync() + { + if (!this.Settings.CheckForUpdates) + return; + + new Thread(() => + { + try + { + GitRelease release = UpdateHelper.GetLatestVersionAsync(Constants.GitHubRepository).Result; + ISemanticVersion latestVersion = new SemanticVersion(release.Tag); + if (latestVersion.IsNewerThan(Constants.ApiVersion)) + this.Monitor.Log($"You can update SMAPI from version {Constants.ApiVersion} to {latestVersion}", LogLevel.Alert); + } + catch (Exception ex) + { + 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.\n{ex.GetLogSummary()}"); + } + }).Start(); + } + + /// <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="contentManager">The content manager to use for mod content.</param> +#if SMAPI_1_x + /// <param name="deprecationWarnings">A list to populate with any deprecation warnings.</param> + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager, IList<Action> deprecationWarnings) +#else + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager) +#endif + { +#if SMAPI_1_x + this.Monitor.Log("Loading mods..."); +#else + this.Monitor.Log("Loading mods...", LogLevel.Trace); +#endif + // load mod assemblies + IDictionary<IModMetadata, string> skippedMods = new Dictionary<IModMetadata, string>(); + { + void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + + AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor); + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); + foreach (IModMetadata metadata in mods) + { + // get basic info + IManifest manifest = metadata.Manifest; + string assemblyPath = metadata.Manifest?.EntryDll != null + ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) + : null; + this.Monitor.Log(assemblyPath != null + ? $"Loading {metadata.DisplayName} from {assemblyPath.Replace(Constants.ModPath, "").TrimStart(Path.DirectorySeparatorChar)}..." + : $"Loading {metadata.DisplayName}...", LogLevel.Trace); + + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + TrackSkip(metadata, metadata.Error); + continue; + } + + // preprocess & load mod assembly + Assembly modAssembly; + try + { + modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.Compatibility?.Compatibility == ModCompatibilityType.AssumeCompatible); + } + catch (IncompatibleInstructionException ex) + { +#if SMAPI_1_x + TrackSkip(metadata, $"it's not compatible with the latest version of the game or SMAPI (detected {ex.NounPhrase}). Please check for a newer version of the mod."); +#else + TrackSkip(metadata, $"it's no longer compatible (detected {ex.NounPhrase}). Please check for a newer version of the mod."); +#endif + continue; + } + catch (SAssemblyLoadFailedException ex) + { + TrackSkip(metadata, $"its DLL '{manifest.EntryDll}' couldn't be loaded: {ex.Message}"); + continue; + } + catch (Exception ex) + { + TrackSkip(metadata, $"its DLL '{manifest.EntryDll}' couldn't be loaded:\n{ex.GetLogSummary()}"); + continue; + } + + // validate assembly + try + { + int modEntries = modAssembly.DefinedTypes.Count(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + if (modEntries == 0) + { + TrackSkip(metadata, $"its DLL has no '{nameof(Mod)}' subclass."); + continue; + } + if (modEntries > 1) + { + TrackSkip(metadata, $"its DLL contains multiple '{nameof(Mod)}' subclasses."); + continue; + } + } + catch (Exception ex) + { + TrackSkip(metadata, $"its DLL couldn't be loaded:\n{ex.GetLogSummary()}"); + continue; + } + + // initialise mod + try + { + // get implementation + TypeInfo modEntryType = modAssembly.DefinedTypes.First(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + Mod mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); + if (mod == null) + { + TrackSkip(metadata, "its entry class couldn't be instantiated."); + continue; + } + +#if SMAPI_1_x + // prevent mods from using SMAPI 2.0 content interception before release + // ReSharper disable SuspiciousTypeConversion.Global + if (mod is IAssetEditor || mod is IAssetLoader) + { + TrackSkip(metadata, $"its entry class implements {nameof(IAssetEditor)} or {nameof(IAssetLoader)}. These are part of a prototype API that isn't available for mods to use yet."); + } + // ReSharper restore SuspiciousTypeConversion.Global +#endif + + // inject data + { + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager); + IContentHelper contentHelper = new ContentHelper(contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry); + ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); + + mod.ModManifest = manifest; + mod.Helper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); + mod.Monitor = monitor; +#if SMAPI_1_x + mod.PathOnDisk = metadata.DirectoryPath; +#endif + } + + // track mod + metadata.SetMod(mod); + this.ModRegistry.Add(metadata); + } + catch (Exception ex) + { + TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); + } + } + } + IModMetadata[] loadedMods = this.ModRegistry.GetMods().ToArray(); + + // log skipped mods +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + if (skippedMods.Any()) + { + this.Monitor.Log($"Skipped {skippedMods.Count} mods:", LogLevel.Error); + foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) + { + IModMetadata mod = pair.Key; + string reason = pair.Value; + + if (mod.Manifest?.Version != null) + this.Monitor.Log($" {mod.DisplayName} {mod.Manifest.Version} because {reason}", LogLevel.Error); + else + this.Monitor.Log($" {mod.DisplayName} because {reason}", LogLevel.Error); + } +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + } + + // 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 + ); + } +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + + // initialise translations + this.ReloadTranslations(); + + // initialise loaded mods + foreach (IModMetadata metadata in loadedMods) + { + // add interceptors + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + this.ContentManager.Editors[metadata] = helper.ObservableAssetEditors; + this.ContentManager.Loaders[metadata] = helper.ObservableAssetLoaders; + } + + // call entry method + try + { + IMod mod = metadata.Mod; + mod.Entry(mod.Helper); +#if SMAPI_1_x + (mod as Mod)?.Entry(); // deprecated since 1.0 + + // raise deprecation warning for old Entry() methods + if (this.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) })) + deprecationWarnings.Add(() => this.DeprecationManager.Warn(metadata.DisplayName, $"{nameof(Mod)}.{nameof(Mod.Entry)}(object[]) instead of {nameof(Mod)}.{nameof(Mod.Entry)}({nameof(IModHelper)})", "1.0", DeprecationLevel.PendingRemoval)); +#else + if (!this.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(IModHelper) })) + this.Monitor.Log($"{metadata.DisplayName} doesn't implement Entry() and may not work correctly.", LogLevel.Error); +#endif + } + catch (Exception ex) + { + this.Monitor.Log($"{metadata.DisplayName} failed on entry and might not work correctly. Technical details:\n{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.ContentManager.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.ContentManager.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 => ((ContentHelper)p.Mod.Helper.Content).AssetEditors).ToArray(); + IAssetLoader[] loaders = loadedMods.SelectMany(p => ((ContentHelper)p.Mod.Helper.Content).AssetLoaders).ToArray(); + if (editors.Any() || loaders.Any()) + { + this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(editors, loaders); + } + } + + /// <summary>Reload translations for all mods.</summary> + private void ReloadTranslations() + { + JsonHelper jsonHelper = new JsonHelper(); + foreach (IModMetadata metadata in this.ModRegistry.GetMods()) + { + // 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 + { + translations[locale] = jsonHelper.ReadJsonFile<IDictionary<string, string>>(file.FullName); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't read {metadata.DisplayName}'s i18n/{locale}.json file: {ex.GetLogSummary()}"); + } + } + } + + // 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()) + { + Framework.Command result = this.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 + { +#if SMAPI_1_x + this.Monitor.Log("The following commands are registered: " + string.Join(", ", this.CommandManager.GetAll().Select(p => p.Name)) + ".", LogLevel.Info); + this.Monitor.Log("For more information about a command, type 'help command_name'.", LogLevel.Info); +#else + string message = "The following commands are registered:\n"; + IGrouping<string, string>[] groups = (from command in this.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); +#endif + } + break; + + case "reload_i18n": + this.ReloadTranslations(); + 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) + { + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; // intercept potential exceptions + 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); + } + + /// <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 static 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) + { + WriteToConsole = this.Monitor.WriteToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, +#if !SMAPI_1_x + ShowFullStampInConsole = this.Settings.DeveloperMode +#endif + }; + } + + /// <summary>Get a human-readable name for the current platform.</summary> + [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] + private string GetFriendlyPlatformName() + { +#if SMAPI_FOR_WINDOWS + try + { + return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") + .Get() + .Cast<ManagementObject>() + .Select(entry => entry.GetPropertyValue("Caption").ToString()) + .FirstOrDefault(); + } + catch { } +#endif + return Environment.OSVersion.ToString(); + } + } +} |