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.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 IReflectionHelper Reflection = new ReflectionHelper();
/// 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.GetGameDisplayVersion(Constants.GameVersion)} on {this.GetFriendlyPlatformName()}", LogLevel.Info);
this.Monitor.Log($"Mods go here: {Constants.ModPath}");
// 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.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);
this.PressAnyKeyToExit();
return;
}
if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion))
{
this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.GetGameDisplayVersion(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.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;
}
// 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
this.Monitor.Log("Starting game...", LogLevel.Trace);
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();
}
}
/// 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);
}
/// 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.GetGameDisplayVersion(Constants.GameVersion)}, but the oldest supported version is {Constants.GetGameDisplayVersion(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();
// 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
// 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;
this.Monitor.ShowFullStampInConsole = 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.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
{
this.Monitor.Log("Loading mod metadata...", LogLevel.Trace);
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
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.Info));
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()}");
}
}
}
// process dependencies
mods = resolver.ProcessDependencies(mods).ToArray();
// load mods
this.LoadMods(mods, new JsonHelper(), this.ContentManager, deprecationWarnings);
foreach (Action warning in deprecationWarnings)
warning();
}
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.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();
}
/// 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
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.
/// A list to populate with any deprecation warnings.
private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager, IList deprecationWarnings)
{
this.Monitor.Log("Loading mods...", LogLevel.Trace);
// 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)
{
TrackSkip(metadata, $"it's not compatible with the latest version of the game (detected {ex.NounPhrase}). Please check for a newer version of the mod (you have v{manifest.Version}).");
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;
}
// 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
// inject data
mod.ModManifest = manifest;
mod.Helper = new ModHelper(metadata.DisplayName, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, this.Reflection);
mod.Monitor = this.GetSecondaryMonitor(metadata.DisplayName);
mod.PathOnDisk = metadata.DirectoryPath;
// 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
this.Monitor.Newline();
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;
this.Monitor.Log($" {mod.DisplayName} {mod.Manifest.Version} because {reason}", LogLevel.Error);
}
this.Monitor.Newline();
}
// 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();
// 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 as Mod)?.Entry(); // deprecated since 1.0
mod.Entry(mod.Helper);
// 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.Info));
}
catch (Exception ex)
{
this.Monitor.Log($"The {metadata.DisplayName} mod failed on entry initialisation. It will still be loaded, but may not function correctly.\n{ex.GetLogSummary()}", LogLevel.Warn);
}
}
// 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)
{
helper.ObservableAssetEditors.CollectionChanged += (sender, e) =>
{
if (e.NewItems.Count > 0)
this.ContentManager.Reset();
};
helper.ObservableAssetLoaders.CollectionChanged += (sender, e) =>
{
if (e.NewItems.Count > 0)
this.ContentManager.Reset();
};
}
}
this.ContentManager.Reset();
}
/// 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
{
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);
}
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,
ShowFullStampInConsole = this.Settings.DeveloperMode
};
}
/// 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();
}
}
}