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.AssemblyRewriters; using StardewModdingAPI.Events; using StardewModdingAPI.Framework; 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 { /// The main entry point for SMAPI, responsible for hooking into and launching the game. internal class Program : IDisposable { /********* ** Properties *********/ /// The log file to which to write messages. private readonly LogFileManager LogFile; /// Manages console output interception. private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); /// The core logger and monitor for SMAPI. private readonly Monitor Monitor; /// Tracks whether the game should exit immediately and any pending initialisation should be cancelled. private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); /// Simplifies access to private game code. private readonly Reflector Reflection = new Reflector(); /// The underlying game instance. private SGame GameInstance; /// The underlying content manager. private SContentManager ContentManager => (SContentManager)this.GameInstance.Content; /// The SMAPI configuration settings. /// This is initialised after the game starts. private SConfig Settings; /// Tracks the installed mods. /// This is initialised after the game starts. private ModRegistry ModRegistry; /// Manages deprecation warnings. /// This is initialised after the game starts. private DeprecationManager DeprecationManager; /// Manages console commands. /// This is initialised after the game starts. private CommandManager CommandManager; /// Whether the game is currently running. private bool IsGameRunning; /// Whether the program has been disposed. private bool IsDisposed; /********* ** Public methods *********/ /// The main entry point which hooks into and launches the game. /// The command-line arguments. 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(); } /// Construct an instance. /// Whether to output log messages to the console. /// The full file path to which to write log messages. public Program(bool writeToConsole, string logPath) { this.LogFile = new LogFileManager(logPath); this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = writeToConsole }; } /// Launch SMAPI. [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}"); #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(); this.GameInstance.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(this.Monitor, sender, e); 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 /// Get a monitor for legacy code which doesn't have one passed in. [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 /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 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 *********/ /// Assert that the minimum conditions are present to initialise SMAPI without type load exceptions. 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." ); } } /// Initialise SMAPI and mods after the game starts. private void InitialiseAfterGameStart() { // load settings this.Settings = JsonConvert.DeserializeObject(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 deprecationWarnings = new List(); foreach (IModMetadata mod in mods.Where(m => m.Status != ModMetadataStatus.Failed)) { // missing fields that will be required in SMAPI 2.0 { List missingFields = new List(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(); } /// Handle the game changing locale. 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); } /// Run a loop handling console input. [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 ' 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 \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(); } /// Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated. /// Returns whether all integrity checks passed. private bool ValidateContentIntegrity() { this.Monitor.Log("Detecting common issues...", LogLevel.Trace); bool issuesFound = false; // object format (commonly broken by outdated files) { // detect issues bool hasObjectIssues = false; void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); foreach (KeyValuePair entry in Game1.objectInformation) { // must not be empty if (string.IsNullOrWhiteSpace(entry.Value)) { LogIssue(entry.Key, "entry is empty"); hasObjectIssues = true; continue; } // require core fields string[] fields = entry.Value.Split('/'); if (fields.Length < 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; } /// Asynchronously check for a new version of SMAPI, and print a message to the console if an update is available. 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(); } /// Create a directory path if it doesn't exist. /// The directory path. private void VerifyPath(string path) { try { if (!Directory.Exists(path)) Directory.CreateDirectory(path); } catch (Exception ex) { this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error); } } /// Load and hook up the given mods. /// The mods to load. /// The JSON helper with which to read mods' JSON files. /// The content manager to use for mod content. #if SMAPI_1_x /// A list to populate with any deprecation warnings. private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager, IList 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 skippedMods = new Dictionary(); { 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(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 (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, 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); } } // reset cache when needed // only register listeners after Entry to avoid repeatedly reloading assets during load foreach (IModMetadata metadata in loadedMods) { if (metadata.Mod.Helper.Content is ContentHelper helper) { // TODO: optimise by only reloading assets the new editors/loaders can intercept helper.ObservableAssetEditors.CollectionChanged += (sender, e) => { if (e.NewItems.Count > 0) { this.Monitor.Log("Detected new asset editor, resetting cache...", LogLevel.Trace); this.ContentManager.InvalidateCache((key, type) => true); } }; helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => { if (e.NewItems.Count > 0) { this.Monitor.Log("Detected new asset loader, resetting cache...", LogLevel.Trace); this.ContentManager.InvalidateCache((key, type) => true); } }; } } this.Monitor.Log("Resetting cache to enable interception...", LogLevel.Trace); this.ContentManager.InvalidateCache((key, type) => true); } /// Reload translations for all mods. private void ReloadTranslations() { JsonHelper jsonHelper = new JsonHelper(); foreach (IModMetadata metadata in this.ModRegistry.GetMods()) { // read translation files IDictionary> translations = new Dictionary>(); DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); if (translationsDir.Exists) { foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) { string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); try { translations[locale] = jsonHelper.ReadJsonFile>(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); } } /// The method called when the user submits a core SMAPI command in the console. /// The command name. /// The command arguments. private void HandleCommand(string name, string[] arguments) { switch (name) { case "help": if (arguments.Any()) { 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[] 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}'."); } } /// Redirect messages logged directly to the console to the given monitor. /// The monitor with which to log messages. /// The message to log. private void HandleConsoleMessage(IMonitor monitor, string message) { LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; // intercept potential exceptions monitor.Log(message, level); } /// Show a 'press any key to exit' message, and exit when they press a key. private void PressAnyKeyToExit() { this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); Program.PressAnyKeyToExit(showMessage: false); } /// Show a 'press any key to exit' message, and exit when they press a key. /// Whether to print a 'press any key to exit' message to the console. private 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); } /// Get a monitor instance derived from SMAPI's current settings. /// The name of the module which will log messages with this instance. private Monitor GetSecondaryMonitor(string name) { return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = this.Monitor.WriteToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, #if !SMAPI_1_x ShowFullStampInConsole = this.Settings.DeveloperMode #endif }; } /// Get a human-readable name for the current platform. [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() .Select(entry => entry.GetPropertyValue("Caption").ToString()) .FirstOrDefault(); } catch { } #endif return Environment.OSVersion.ToString(); } } }