using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
#if SMAPI_FOR_WINDOWS
using System.Windows.Forms;
#endif
using Microsoft.Xna.Framework.Graphics;
using Newtonsoft.Json;
using StardewModdingAPI.AssemblyRewriters;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework;
using StardewModdingAPI.Framework.AssemblyRewriting;
using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Inheritance;
using StardewValley;
using Monitor = StardewModdingAPI.Framework.Monitor;
namespace StardewModdingAPI
{
/// The main entry point for SMAPI, responsible for hooking into and launching the game.
public class Program
{
/*********
** Properties
*********/
/// The target game platform.
private static readonly Platform TargetPlatform =
#if SMAPI_FOR_WINDOWS
Platform.Windows;
#else
Platform.Mono;
#endif
/// The full path to the Stardew Valley executable.
private static readonly string GameExecutablePath = Path.Combine(Constants.ExecutionPath, Program.TargetPlatform == Platform.Windows ? "Stardew Valley.exe" : "StardewValley.exe");
/// The full path to the folder containing mods.
private static readonly string ModPath = Path.Combine(Constants.ExecutionPath, "Mods");
/// The name of the folder containing a mod's cached assembly data.
private static readonly string CacheDirName = ".cache";
/// The log file to which to write messages.
private static readonly LogFileManager LogFile = new LogFileManager(Constants.LogPath);
/// The core logger for SMAPI.
private static readonly Monitor Monitor = new Monitor("SMAPI", Program.LogFile);
/// The user settings for SMAPI.
private static UserSettings Settings;
/// Tracks whether the game should exit immediately and any pending initialisation should be cancelled.
private static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource();
/*********
** Accessors
*********/
/// The number of mods currently loaded by SMAPI.
public static int ModsLoaded;
/// The underlying game instance.
public static SGame gamePtr;
/// Whether the game is currently running.
public static bool ready;
/// The underlying game assembly.
public static Assembly StardewAssembly;
/// The underlying type.
public static Type StardewProgramType;
/// The field containing game's main instance.
public static FieldInfo StardewGameInfo;
// ReSharper disable once PossibleNullReferenceException
/// The game's build type (i.e. GOG vs Steam).
public static int BuildType => (int)Program.StardewProgramType.GetField("buildType", BindingFlags.Public | BindingFlags.Static).GetValue(null);
/// Tracks the installed mods.
internal static readonly ModRegistry ModRegistry = new ModRegistry();
/// Manages deprecation warnings.
internal static readonly DeprecationManager DeprecationManager = new DeprecationManager(Program.Monitor, Program.ModRegistry);
/*********
** Public methods
*********/
/// The main entry point which hooks into and launches the game.
private static void Main()
{
// set thread culture for consistent log formatting
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-GB");
// add info header
Program.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version} on {Environment.OSVersion}", LogLevel.Info);
// initialise user settings
{
string settingsPath = Constants.ApiConfigPath;
if (File.Exists(settingsPath))
{
string json = File.ReadAllText(settingsPath);
Program.Settings = JsonConvert.DeserializeObject(json);
}
else
Program.Settings = new UserSettings();
File.WriteAllText(settingsPath, JsonConvert.SerializeObject(Program.Settings, Formatting.Indented));
}
// add warning headers
if (Program.Settings.DeveloperMode)
{
Program.Monitor.ShowTraceInConsole = true;
Program.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 or deleting {Constants.ApiConfigPath}.", LogLevel.Warn);
}
if (!Program.Settings.CheckForUpdates)
Program.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 editing or deleting {Constants.ApiConfigPath}.", LogLevel.Warn);
// initialise legacy log
Log.Monitor = new Monitor("legacy mod", Program.LogFile) { ShowTraceInConsole = Program.Settings.DeveloperMode };
Log.ModRegistry = Program.ModRegistry;
// hook into & launch the game
try
{
// verify version
if (String.Compare(Game1.version, Constants.MinimumGameVersion, StringComparison.InvariantCultureIgnoreCase) < 0)
{
Program.Monitor.Log($"Oops! You're running Stardew Valley {Game1.version}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI. If you're on the Steam beta channel, note that the beta channel may not receive the latest updates.", LogLevel.Error);
return;
}
// initialise
Program.Monitor.Log("Loading SMAPI...");
Console.Title = Constants.ConsoleTitle;
Program.VerifyPath(Program.ModPath);
Program.VerifyPath(Constants.LogDir);
if (!File.Exists(Program.GameExecutablePath))
{
Program.Monitor.Log($"Couldn't find executable: {Program.GameExecutablePath}", LogLevel.Error);
Program.PressAnyKeyToExit();
return;
}
// check for update when game loads
if (Program.Settings.CheckForUpdates)
GameEvents.GameLoaded += (sender, e) => Program.CheckForUpdateAsync();
// launch game
Program.StartGame();
}
catch (Exception ex)
{
Program.Monitor.Log($"Critical error: {ex.GetLogSummary()}", LogLevel.Error);
}
Program.PressAnyKeyToExit();
}
/// Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.
/// The module which requested an immediate exit.
/// The reason provided for the shutdown.
internal static void ExitGameImmediately(string module, string reason)
{
Program.Monitor.LogFatal($"{module} requested an immediate game shutdown: {reason}");
Program.CancellationTokenSource.Cancel();
if (Program.ready)
{
Program.gamePtr.Exiting += (sender, e) => Program.PressAnyKeyToExit();
Program.gamePtr.Exit();
}
}
/// 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 static IMonitor GetLegacyMonitorForMod()
{
string modName = Program.ModRegistry.GetModFromStack() ?? "unknown";
return new Monitor(modName, Program.LogFile);
}
/*********
** Private methods
*********/
/// Asynchronously check for a new version of SMAPI, and print a message to the console if an update is available.
private static void CheckForUpdateAsync()
{
new Thread(() =>
{
try
{
GitRelease release = UpdateHelper.GetLatestVersionAsync(Constants.GitHubRepository).Result;
ISemanticVersion latestVersion = new SemanticVersion(release.Tag);
if (latestVersion.IsNewerThan(Constants.ApiVersion))
Program.Monitor.Log($"You can update SMAPI from version {Constants.ApiVersion} to {latestVersion}", LogLevel.Alert);
}
catch (Exception ex)
{
Program.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();
}
/// Hook into Stardew Valley and launch the game.
private static void StartGame()
{
try
{
// load the game assembly
Program.Monitor.Log("Loading game...");
Program.StardewAssembly = Assembly.UnsafeLoadFrom(Program.GameExecutablePath);
Program.StardewProgramType = Program.StardewAssembly.GetType("StardewValley.Program", true);
Program.StardewGameInfo = Program.StardewProgramType.GetField("gamePtr");
Game1.version += $"-Z_MODDED | SMAPI {Constants.ApiVersion}";
// add error interceptors
#if SMAPI_FOR_WINDOWS
Application.ThreadException += (sender, e) => Program.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error);
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
#endif
AppDomain.CurrentDomain.UnhandledException += (sender, e) => Program.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
// initialise game instance
Program.gamePtr = new SGame(Program.Monitor) { IsMouseVisible = false };
Program.gamePtr.Exiting += (sender, e) => Program.ready = false;
Program.gamePtr.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(Program.Monitor, sender, e);
Program.gamePtr.Window.Title = $"Stardew Valley - Version {Game1.version}";
Program.StardewGameInfo.SetValue(Program.StardewProgramType, Program.gamePtr);
// patch graphics
Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef;
// load mods
Program.LoadMods();
if (Program.CancellationTokenSource.IsCancellationRequested)
{
Program.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error);
return;
}
// initialise console after game launches
new Thread(() =>
{
// wait for the game to load up
while (!Program.ready) Thread.Sleep(1000);
// register help command
Command.RegisterCommand("help", "Lists all commands | 'help ' returns command description").CommandFired += Program.help_CommandFired;
// listen for command line input
Program.Monitor.Log("Starting console...");
Program.Monitor.Log("Type 'help' for help, or 'help ' for a command's usage", LogLevel.Info);
Thread consoleInputThread = new Thread(Program.ConsoleInputLoop);
consoleInputThread.Start();
while (Program.ready)
Thread.Sleep(1000 / 10); // Check if the game is still running 10 times a second
// abort the console thread, we're closing
if (consoleInputThread.ThreadState == ThreadState.Running)
consoleInputThread.Abort();
}).Start();
// start game loop
Program.Monitor.Log("Starting game...");
if (Program.CancellationTokenSource.IsCancellationRequested)
{
Program.Monitor.Log("Shutdown requested; interrupting initialisation.", LogLevel.Error);
return;
}
try
{
Program.ready = true;
Program.gamePtr.Run();
}
finally
{
Program.ready = false;
}
}
catch (Exception ex)
{
Program.Monitor.Log($"SMAPI encountered a fatal error:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
/// Create a directory path if it doesn't exist.
/// The directory path.
private static void VerifyPath(string path)
{
try
{
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
}
catch (Exception ex)
{
Program.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
/// Load and hook up all mods in the mod directory.
private static void LoadMods()
{
Program.Monitor.Log("Loading mods...");
// get assembly loader
ModAssemblyLoader modAssemblyLoader = new ModAssemblyLoader(Program.CacheDirName, Program.TargetPlatform, Program.Monitor);
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name);
// get known incompatible mods
IDictionary incompatibleMods = File.Exists(Constants.ApiModMetadataPath)
? JsonConvert.DeserializeObject(File.ReadAllText(Constants.ApiModMetadataPath)).ToDictionary(p => p.ID, p => p)
: new Dictionary(0);
// load mods
foreach (string directory in Directory.GetDirectories(Program.ModPath))
{
string directoryName = new DirectoryInfo(directory).Name;
// ignore internal directory
if (directoryName == ".cache")
continue;
// check for cancellation
if (Program.CancellationTokenSource.IsCancellationRequested)
{
Program.Monitor.Log("Shutdown requested; interrupting mod loading.", LogLevel.Error);
return;
}
// get helper
IModHelper helper = new ModHelper(directory);
// get manifest path
string manifestPath = Path.Combine(directory, "manifest.json");
if (!File.Exists(manifestPath))
{
Program.Monitor.Log($"Ignored folder \"{directoryName}\" which doesn't have a manifest.json.", LogLevel.Warn);
continue;
}
string errorPrefix = $"Couldn't load mod for manifest '{manifestPath}'";
// read manifest
ManifestImpl manifest;
try
{
// read manifest text
string json = File.ReadAllText(manifestPath);
if (string.IsNullOrEmpty(json))
{
Program.Monitor.Log($"{errorPrefix}: manifest is empty.", LogLevel.Error);
continue;
}
// deserialise manifest
manifest = helper.ReadJsonFile("manifest.json");
if (manifest == null)
{
Program.Monitor.Log($"{errorPrefix}: the manifest file does not exist.", LogLevel.Error);
continue;
}
if (string.IsNullOrEmpty(manifest.EntryDll))
{
Program.Monitor.Log($"{errorPrefix}: manifest doesn't specify an entry DLL.", LogLevel.Error);
continue;
}
// log deprecated fields
if (manifest.UsedAuthourField)
Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.Authour)}", "1.0", DeprecationLevel.Notice);
}
catch (Exception ex)
{
Program.Monitor.Log($"{errorPrefix}: manifest parsing failed.\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}
// validate known incompatible mods
IncompatibleMod compatibility;
if (incompatibleMods.TryGetValue(manifest.UniqueID ?? $"{manifest.Name}|{manifest.Author}|{manifest.EntryDll}", out compatibility))
{
if (!compatibility.IsCompatible(manifest.Version))
{
string warning = $"Skipped {compatibility.Name} ≤v{compatibility.Version} because this version is not compatible with the latest version of the game. Please check for a newer version of the mod here:";
if (!string.IsNullOrWhiteSpace(compatibility.UpdateUrl))
warning += $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}";
if (!string.IsNullOrWhiteSpace(compatibility.UnofficialUpdateUrl))
warning += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}";
Program.Monitor.Log(warning, LogLevel.Error);
continue;
}
}
// validate SMAPI version
if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion))
{
try
{
ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion);
if (minVersion.IsNewerThan(Constants.ApiVersion))
{
Program.Monitor.Log($"{errorPrefix}: this mod requires SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod.", LogLevel.Error);
continue;
}
}
catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version"))
{
Program.Monitor.Log($"{errorPrefix}: the mod specified an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.Version}.", LogLevel.Error);
continue;
}
}
// create per-save directory
if (manifest.PerSaveConfigs)
{
Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Notice);
try
{
string psDir = Path.Combine(directory, "psconfigs");
Directory.CreateDirectory(psDir);
if (!Directory.Exists(psDir))
{
Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod. The failure reason is unknown.", LogLevel.Error);
continue;
}
}
catch (Exception ex)
{
Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod.\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}
}
// preprocess mod assemblies for compatibility
var processedAssemblies = new List();
{
bool succeeded = true;
foreach (string assemblyPath in Directory.GetFiles(directory, "*.dll"))
{
try
{
processedAssemblies.Add(modAssemblyLoader.ProcessAssemblyUnlessCached(assemblyPath));
}
catch (Exception ex)
{
Program.Monitor.Log($"{errorPrefix}: an error occurred while preprocessing '{Path.GetFileName(assemblyPath)}'.\n{ex.GetLogSummary()}", LogLevel.Error);
succeeded = false;
break;
}
}
if (!succeeded)
continue;
}
bool forceUseCachedAssembly = processedAssemblies.Any(p => p.UseCachedAssembly); // make sure DLLs are kept together for dependency resolution
if (processedAssemblies.Any(p => p.IsNewerThanCache))
modAssemblyLoader.WriteCache(processedAssemblies, forceUseCachedAssembly);
// get entry assembly path
string mainAssemblyPath;
{
RewriteResult mainProcessedAssembly = processedAssemblies.FirstOrDefault(p => p.OriginalAssemblyPath == Path.Combine(directory, manifest.EntryDll));
if (mainProcessedAssembly == null)
{
Program.Monitor.Log($"{errorPrefix}: the specified mod DLL does not exist.", LogLevel.Error);
continue;
}
mainAssemblyPath = forceUseCachedAssembly ? mainProcessedAssembly.CachePaths.Assembly : mainProcessedAssembly.OriginalAssemblyPath;
}
// load entry assembly
Assembly modAssembly;
try
{
modAssembly = Assembly.UnsafeLoadFrom(mainAssemblyPath); // unsafe load allows downloaded DLLs
if (modAssembly.DefinedTypes.Count(x => x.BaseType == typeof(Mod)) == 0)
{
Program.Monitor.Log($"{errorPrefix}: the mod DLL does not contain an implementation of the 'Mod' class.", LogLevel.Error);
continue;
}
}
catch (Exception ex)
{
Program.Monitor.Log($"{errorPrefix}: an error occurred while optimising the target DLL.\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}
// get mod instance
Mod mod;
try
{
// get implementation
TypeInfo modEntryType = modAssembly.DefinedTypes.First(x => x.BaseType == typeof(Mod));
mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString());
if (mod == null)
{
Program.Monitor.Log($"{errorPrefix}: the mod's entry class could not be instantiated.");
continue;
}
// inject data
mod.ModManifest = manifest;
mod.Helper = helper;
mod.Monitor = new Monitor(manifest.Name, Program.LogFile) { ShowTraceInConsole = Program.Settings.DeveloperMode };
mod.PathOnDisk = directory;
// track mod
Program.ModRegistry.Add(mod);
Program.ModsLoaded += 1;
Program.Monitor.Log($"Loaded mod: {manifest.Name} by {manifest.Author}, v{manifest.Version} | {manifest.Description}", LogLevel.Info);
}
catch (Exception ex)
{
Program.Monitor.Log($"{errorPrefix}: an error occurred while loading the target DLL.\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}
// call mod entry
try
{
// call entry methods
mod.Entry(); // deprecated since 1.0
mod.Entry((ModHelper)mod.Helper); // deprecated since 1.1
mod.Entry(mod.Helper);
// raise deprecation warning for old Entry() methods
if (Program.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) }))
Program.DeprecationManager.Warn(manifest.Name, $"an old version of {nameof(Mod)}.{nameof(Mod.Entry)}", "1.0", DeprecationLevel.Notice);
if (Program.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(ModHelper) }))
Program.DeprecationManager.Warn(manifest.Name, $"an old version of {nameof(Mod)}.{nameof(Mod.Entry)}", "1.1", DeprecationLevel.Notice);
}
catch (Exception ex)
{
Program.Monitor.Log($"The {manifest.Name} mod failed on entry initialisation. It will still be loaded, but may not function correctly.\n{ex.GetLogSummary()}", LogLevel.Warn);
}
}
// print result
Program.Monitor.Log($"Loaded {Program.ModsLoaded} mods.");
Console.Title = Constants.ConsoleTitle;
}
// ReSharper disable once FunctionNeverReturns
/// Run a loop handling console input.
private static void ConsoleInputLoop()
{
while (true)
Command.CallCommand(Console.ReadLine(), Program.Monitor);
}
/// The method called when the user submits the help command in the console.
/// The event sender.
/// The event data.
private static void help_CommandFired(object sender, EventArgsCommand e)
{
if (e.Command.CalledArgs.Length > 0)
{
var command = Command.FindCommand(e.Command.CalledArgs[0]);
if (command == null)
Program.Monitor.Log("The specified command could't be found", LogLevel.Error);
else
Program.Monitor.Log(command.CommandArgs.Length > 0 ? $"{command.CommandName}: {command.CommandDesc} - {string.Join(", ", command.CommandArgs)}" : $"{command.CommandName}: {command.CommandDesc}", LogLevel.Info);
}
else
Program.Monitor.Log("Commands: " + string.Join(", ", Command.RegisteredCommands.Select(x => x.CommandName)), LogLevel.Info);
}
/// Show a 'press any key to exit' message, and exit when they press a key.
private static void PressAnyKeyToExit()
{
Program.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info);
Thread.Sleep(100);
Console.ReadKey();
Environment.Exit(0);
}
}
}