using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Xna.Framework;
#if SMAPI_FOR_WINDOWS
using Microsoft.Win32;
#endif
using Newtonsoft.Json;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Logging;
using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Rendering;
using StardewModdingAPI.Framework.Serialization;
#if SMAPI_DEPRECATED
using StardewModdingAPI.Framework.StateTracking.Comparers;
#endif
using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal;
using StardewModdingAPI.Internal.Patching;
using StardewModdingAPI.Patches;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;
using StardewModdingAPI.Utilities;
using StardewValley;
using StardewValley.Menus;
using StardewValley.Objects;
using StardewValley.SDKs;
using xTile.Display;
using LanguageCode = StardewValley.LocalizedContentManager.LanguageCode;
using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix;
using PathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities;
using SObject = StardewValley.Object;
namespace StardewModdingAPI.Framework
{
/// The core class which initializes and manages SMAPI.
internal class SCore : IDisposable
{
/*********
** Fields
*********/
/****
** Low-level components
****/
/// A state which indicates whether SMAPI should exit immediately and any pending initialization should be cancelled.
private ExitState ExitState;
/// Whether the game should exit immediately and any pending initialization should be cancelled.
private bool IsExiting => this.ExitState != ExitState.None;
/// Manages the SMAPI console window and log file.
private readonly LogManager LogManager;
/// The core logger and monitor for SMAPI.
private Monitor Monitor => this.LogManager.Monitor;
/// Simplifies access to private game code.
private readonly Reflector Reflection = new();
/// Encapsulates access to SMAPI core translations.
private readonly Translator Translator = new();
/// The SMAPI configuration settings.
private readonly SConfig Settings;
/// The mod toolkit used for generic mod interactions.
private readonly ModToolkit Toolkit = new();
/****
** Higher-level components
****/
/// Manages console commands.
private readonly CommandManager CommandManager;
/// The underlying game instance.
private SGameRunner Game = null!; // initialized very early
/// SMAPI's content manager.
private ContentCoordinator ContentCore = null!; // initialized very early
/// The game's core multiplayer utility for the main player.
private SMultiplayer Multiplayer = null!; // initialized very early
/// Tracks the installed mods.
/// This is initialized after the game starts.
private readonly ModRegistry ModRegistry = new();
/// Manages SMAPI events for mods.
private readonly EventManager EventManager;
/****
** State
****/
/// The path to search for mods.
private string ModsPath => Constants.ModsPath;
/// Whether the game is currently running.
private bool IsGameRunning;
/// Whether the program has been disposed.
private bool IsDisposed;
/// Whether the next content manager requested by the game will be for .
private bool NextContentManagerIsMain;
/// Whether post-game-startup initialization has been performed.
private bool IsInitialized;
/// Whether the game has initialized for any custom languages from Data/AdditionalLanguages.
private bool AreCustomLanguagesInitialized;
/// Whether the player just returned to the title screen.
public bool JustReturnedToTitle { get; set; }
/// The last language set by the game.
private (string Locale, LanguageCode Code) LastLanguage { get; set; } = ("", LanguageCode.en);
/// The maximum number of consecutive attempts SMAPI should make to recover from an update error.
private readonly Countdown UpdateCrashTimer = new(60); // 60 ticks = roughly one second
#if SMAPI_DEPRECATED
/// Asset interceptors added or removed since the last tick.
private readonly List ReloadAssetInterceptorsQueue = new();
#endif
/// A list of queued commands to parse and execute.
private readonly CommandQueue RawCommandQueue = new();
/// A list of commands to execute on each screen.
private readonly PerScreen> ScreenCommandQueue = new(() => new List());
/*********
** Accessors
*********/
/// Manages deprecation warnings.
/// This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.
internal static DeprecationManager DeprecationManager { get; private set; } = null!; // initialized in constructor, which happens before other code can access it
/// The singleton instance.
/// This is only intended for use by external code like the Error Handler mod.
internal static SCore Instance { get; private set; } = null!; // initialized in constructor, which happens before other code can access it
/// The number of game update ticks which have already executed. This is similar to , but incremented more consistently for every tick.
internal static uint TicksElapsed { get; private set; }
/// A specialized form of which is incremented each time SMAPI performs a processing tick (whether that's a game update, one wait cycle while synchronizing code, etc).
internal static uint ProcessTicksElapsed { get; private set; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The path to search for mods.
/// Whether to output log messages to the console.
/// Whether to enable development features, or null to use the value from the settings file.
public SCore(string modsPath, bool writeToConsole, bool? developerMode)
{
SCore.Instance = this;
// init paths
this.VerifyPath(modsPath);
this.VerifyPath(Constants.LogDir);
Constants.ModsPath = modsPath;
// init log file
this.PurgeNormalLogs();
string logPath = this.GetLogPath();
// init basics
this.Settings = JsonConvert.DeserializeObject(File.ReadAllText(Constants.ApiConfigPath)) ?? throw new InvalidOperationException("The 'smapi-internal/config.json' file is missing or invalid. You can reinstall SMAPI to fix this.");
if (File.Exists(Constants.ApiUserConfigPath))
JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
if (developerMode.HasValue)
this.Settings.OverrideDeveloperMode(developerMode.Value);
this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, verboseLogging: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog);
this.CommandManager = new CommandManager(this.Monitor);
this.EventManager = new EventManager(this.ModRegistry);
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
SDate.Translations = this.Translator;
// log SMAPI/OS info
this.LogManager.LogIntro(modsPath, this.Settings.GetCustomSettings());
// validate platform
#if SMAPI_FOR_WINDOWS
if (Constants.Platform != Platform.Windows)
{
this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or macOS. Please reinstall SMAPI to fix this.", LogLevel.Error);
this.LogManager.PressAnyKeyToExit();
}
#else
if (Constants.Platform == Platform.Windows)
{
this.Monitor.Log($"Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error);
this.LogManager.PressAnyKeyToExit();
}
#endif
}
/// Launch SMAPI.
[HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions
public void RunInteractively()
{
// initialize SMAPI
try
{
JsonConverter[] converters = {
new ColorConverter(),
new KeybindConverter(),
new PointConverter(),
new Vector2Converter(),
new RectangleConverter()
};
foreach (JsonConverter converter in converters)
this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter);
// add error handlers
AppDomain.CurrentDomain.UnhandledException += (_, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
// add more lenient assembly resolver
AppDomain.CurrentDomain.AssemblyResolve += (_, e) => AssemblyLoader.ResolveAssembly(e.Name);
// hook locale event
LocalizedContentManager.OnLanguageChange += _ => this.OnLocaleChanged();
// override game
this.Multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic);
SGame.CreateContentManagerImpl = this.CreateContentManager; // must be static since the game accesses it before the SGame constructor is called
this.Game = new SGameRunner(
monitor: this.Monitor,
reflection: this.Reflection,
eventManager: this.EventManager,
modHooks: new SModHooks(
parent: new ModHooks(),
beforeNewDayAfterFade: this.OnNewDayAfterFade,
monitor: this.Monitor
),
multiplayer: this.Multiplayer,
exitGameImmediately: this.ExitGameImmediately,
onGameContentLoaded: this.OnInstanceContentLoaded,
onGameUpdating: this.OnGameUpdating,
onPlayerInstanceUpdating: this.OnPlayerInstanceUpdating,
onGameExiting: this.OnGameExiting
);
StardewValley.GameRunner.instance = this.Game;
// apply game patches
MiniMonoModHotfix.Apply();
HarmonyPatcher.Apply("SMAPI", this.Monitor,
new Game1Patcher(this.Reflection, this.OnLoadStageChanged),
new TitleMenuPatcher(this.OnLoadStageChanged)
);
// set window titles
this.UpdateWindowTitles();
}
catch (Exception ex)
{
this.Monitor.Log($"SMAPI failed to initialize: {ex.GetLogSummary()}", LogLevel.Error);
this.LogManager.PressAnyKeyToExit();
return;
}
// log basic info
this.LogManager.HandleMarkerFiles();
this.LogManager.LogSettingsHeader(this.Settings);
// set window titles
this.UpdateWindowTitles();
// start game
this.Monitor.Log("Waiting for game to launch...", LogLevel.Debug);
try
{
this.IsGameRunning = true;
StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window
this.Game.Run();
this.Dispose(isError: false);
}
catch (Exception ex)
{
this.LogManager.LogFatalLaunchError(ex);
this.LogManager.PressAnyKeyToExit();
this.Dispose(isError: true);
}
}
/// Get the core logger and monitor on behalf of the game.
/// This method is called using reflection by the ErrorHandler mod to log game errors.
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via reflection")]
public IMonitor GetMonitorForGame()
{
return this.LogManager.MonitorForGame;
}
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "May be disposed before SMAPI is fully initialized.")]
public void Dispose()
{
this.Dispose(isError: true); // if we got here, SMAPI didn't detect the exit before it happened
}
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// Whether the process is exiting due to an error or crash.
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "May be disposed before SMAPI is fully initialized.")]
public void Dispose(bool isError)
{
// skip if already disposed
if (this.IsDisposed)
return;
this.IsDisposed = true;
this.Monitor.Log("Disposing...");
// dispose mod data
foreach (IModMetadata mod in this.ModRegistry.GetAll())
{
try
{
(mod.Mod as IDisposable)?.Dispose();
}
catch (Exception ex)
{
mod.LogAsMod($"Mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn);
}
}
// dispose core components
this.IsGameRunning = false;
if (this.ExitState == ExitState.None || isError)
this.ExitState = isError ? ExitState.Crash : ExitState.GameExit;
this.ContentCore?.Dispose();
this.Game?.Dispose();
this.LogManager.Dispose(); // dispose last to allow for any last-second log messages
// clean up SDK
// This avoids Steam connection errors when it exits unexpectedly. The game avoids this
// by killing the entire process, but we can't set the error code if we do that.
try
{
FieldInfo? field = typeof(StardewValley.Program).GetField("_sdk", BindingFlags.NonPublic | BindingFlags.Static);
SDKHelper? sdk = field?.GetValue(null) as SDKHelper;
sdk?.Shutdown();
}
catch
{
// well, at least we tried
}
// end game with error code
// This helps the OS decide whether to keep the window open (e.g. Windows may keep it open on error).
Environment.Exit(this.ExitState == ExitState.Crash ? 1 : 0);
}
/*********
** Private methods
*********/
/// Initialize mods before the first game asset is loaded. At this point the core content managers are loaded (so mods can load their own assets), but the game is mostly uninitialized.
private void InitializeBeforeFirstAssetLoaded()
{
if (this.IsExiting)
{
this.Monitor.Log("SMAPI shutting down: aborting initialization.", LogLevel.Warn);
return;
}
// init TMX support
xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom));
// load mod data
ModToolkit toolkit = new();
ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath);
// load mods
{
this.Monitor.Log("Loading mod metadata...", LogLevel.Debug);
ModResolver resolver = new();
// log loose files
{
string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray();
if (looseFiles.Any())
{
if (looseFiles.Any(name => name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
{
this.Monitor.Log($"Detected mod files directly inside the '{Path.GetFileName(this.ModsPath)}' folder. These will be ignored. Each mod must have its own subfolder instead.", LogLevel.Error);
}
this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}");
}
}
// load manifests
IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase, useCaseInsensitiveFilePaths: this.Settings.UseCaseInsensitivePaths).ToArray();
// filter out ignored mods
foreach (IModMetadata mod in mods.Where(p => p.IsIgnored))
this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).");
mods = mods.Where(p => !p.IsIgnored).ToArray();
// validate manifests
resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl, getFileLookup: this.GetFileLookup);
// apply load order customizations
if (this.Settings.ModsToLoadEarly.Any() || this.Settings.ModsToLoadLate.Any())
{
HashSet installedIds = new HashSet(mods.Where(p => p.FailReason is null).Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase);
string[] missingEarlyMods = this.Settings.ModsToLoadEarly.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
string[] missingLateMods = this.Settings.ModsToLoadLate.Where(id => !installedIds.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
string[] duplicateMods = this.Settings.ModsToLoadLate.Where(id => this.Settings.ModsToLoadEarly.Contains(id)).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
if (missingEarlyMods.Any())
this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadEarly)} which aren't installed: '{string.Join("', '", missingEarlyMods)}'.", LogLevel.Warn);
if (missingLateMods.Any())
this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs in {nameof(this.Settings.ModsToLoadLate)} which aren't installed: '{string.Join("', '", missingLateMods)}'.", LogLevel.Warn);
if (duplicateMods.Any())
this.Monitor.Log($" The 'smapi-internal/config.json' file lists mod IDs which are in both {nameof(this.Settings.ModsToLoadEarly)} and {nameof(this.Settings.ModsToLoadLate)}: '{string.Join("', '", duplicateMods)}'. These will be loaded early.", LogLevel.Warn);
mods = resolver.ApplyLoadOrderOverrides(mods, this.Settings.ModsToLoadEarly, this.Settings.ModsToLoadLate);
}
// load mods
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
// check for software likely to cause issues
this.CheckForSoftwareConflicts();
// check for updates
_ = this.CheckForUpdatesAsync(mods); // ignore task since the main thread doesn't need to wait for it
}
// update window titles
this.UpdateWindowTitles();
}
/// Raised after the game finishes initializing.
private void OnGameInitialized()
{
// 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);
// start SMAPI console
if (this.Settings.ListenForConsoleInput)
{
new Thread(
() => this.LogManager.RunConsoleInputLoop(
commandManager: this.CommandManager,
reloadTranslations: this.ReloadTranslations,
handleInput: input => this.RawCommandQueue.Add(input),
continueWhile: () => this.IsGameRunning && !this.IsExiting
)
).Start();
}
}
/// Raised after an instance finishes loading its initial content.
private void OnInstanceContentLoaded()
{
// override map display device
Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice);
// log GPU info
#if SMAPI_FOR_WINDOWS
this.Monitor.Log($"Running on GPU: {Game1.game1.GraphicsDevice?.Adapter?.Description ?? ""}");
#endif
}
/// Raised when the game is updating its state (roughly 60 times per second).
/// A snapshot of the game timing state.
/// Invoke the game's update logic.
private void OnGameUpdating(GameTime gameTime, Action runGameUpdate)
{
try
{
/*********
** Safe queued work
*********/
// print warnings/alerts
SCore.DeprecationManager.PrintQueued();
/*********
** First-tick initialization
*********/
if (!this.IsInitialized)
{
this.IsInitialized = true;
this.OnGameInitialized();
}
/*********
** Special cases
*********/
// Abort if SMAPI is exiting.
if (this.IsExiting)
{
this.Monitor.Log("SMAPI shutting down: aborting update.");
return;
}
/*********
** Prevent Harmony debug mode
*********/
if (HarmonyLib.Harmony.DEBUG && this.Settings.SuppressHarmonyDebugMode)
{
HarmonyLib.Harmony.DEBUG = false;
this.Monitor.LogOnce("A mod enabled Harmony debug mode, which impacts performance and creates a file on your desktop. SMAPI will try to keep it disabled. (You can allow debug mode by editing the smapi-internal/config.json file.)", LogLevel.Warn);
}
#if SMAPI_DEPRECATED
/*********
** Reload assets when interceptors are added/removed
*********/
if (this.ReloadAssetInterceptorsQueue.Any())
{
// get unique interceptors
AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
.GroupBy(p => p.Instance, new ObjectReferenceComparer