using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
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 System.Windows.Forms;
#endif
using Newtonsoft.Json;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
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.Patching;
using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Rendering;
using StardewModdingAPI.Framework.Serialization;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
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.Utilities;
using StardewValley;
using SObject = StardewValley.Object;
namespace StardewModdingAPI.Framework
{
/// The core class which initializes and manages SMAPI.
internal class SCore : IDisposable
{
/*********
** Fields
*********/
/****
** Low-level components
****/
/// Tracks whether the game should exit immediately and any pending initialization should be cancelled.
private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource();
/// 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 Reflector();
/// Encapsulates access to SMAPI core translations.
private readonly Translator Translator = new Translator();
/// The SMAPI configuration settings.
private readonly SConfig Settings;
/// The mod toolkit used for generic mod interactions.
private readonly ModToolkit Toolkit = new ModToolkit();
/****
** Higher-level components
****/
/// Manages console commands.
private readonly CommandManager CommandManager = new CommandManager();
/// The underlying game instance.
private SGame Game;
/// Manages input visible to the game.
private SInputState Input => SGame.Input;
/// The game's core multiplayer utility.
private SMultiplayer Multiplayer => SGame.Multiplayer;
/// SMAPI's content manager.
private ContentCoordinator ContentCore;
/// Tracks the installed mods.
/// This is initialized after the game starts.
private readonly ModRegistry ModRegistry = new ModRegistry();
/// Manages SMAPI events for mods.
private readonly EventManager EventManager;
/// Monitors the entire game state for changes.
private WatcherCore Watchers;
/// A snapshot of the current state.
private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
/****
** 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;
/// The maximum number of consecutive attempts SMAPI should make to recover from an update error.
private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second
/// The number of ticks until SMAPI should notify mods that the game has loaded.
/// Skipping a few frames ensures the game finishes initializing the world before mods try to change it.
private readonly Countdown AfterLoadTimer = new Countdown(5);
/// Whether custom content was removed from the save data to avoid a crash.
private bool IsSaveContentRemoved;
/// Whether the game is saving and SMAPI has already raised .
private bool IsBetweenSaveEvents;
/// Whether the game is creating the save file and SMAPI has already raised .
private bool IsBetweenCreateEvents;
/// Whether the player just returned to the title screen.
private bool JustReturnedToTitle;
/// Asset interceptors added or removed since the last tick.
private readonly List ReloadAssetInterceptorsQueue = new List();
/// A list of queued commands to execute.
/// This property must be thread-safe, since it's accessed from a separate console input thread.
public ConcurrentQueue CommandQueue { get; } = new ConcurrentQueue();
/*********
** 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; }
/// Manages performance counters.
/// This is initialized after the game starts. This is non-private for use by Console Commands.
internal static PerformanceMonitor PerformanceMonitor { get; private set; }
/// The number of update ticks which have already executed. This is similar to , but incremented more consistently for every tick.
internal static uint TicksElapsed { get; private set; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The path to search for mods.
/// Whether to output log messages to the console.
public SCore(string modsPath, bool writeToConsole)
{
// 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));
if (File.Exists(Constants.ApiUserConfigPath))
JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode);
SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor);
this.EventManager = new EventManager(this.ModRegistry, SCore.PerformanceMonitor);
SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager);
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 Mac. 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 PointConverter(),
new Vector2Converter(),
new RectangleConverter()
};
foreach (JsonConverter converter in converters)
this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter);
// 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);
// add more lenient assembly resolver
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
// hook locale event
LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged();
// override game
var multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic);
var modHooks = new SModHooks(this.OnNewDayAfterFade);
SGame.CreateContentManagerImpl = this.CreateContentManager; // must be static since the game accesses it before the SGame constructor is called
this.Game = new SGame(
monitor: this.Monitor,
reflection: this.Reflection,
eventManager: this.EventManager,
modHooks: modHooks,
multiplayer: multiplayer,
exitGameImmediately: this.ExitGameImmediately,
onGameContentLoaded: this.OnGameContentLoaded,
onGameUpdating: this.OnGameUpdating,
onGameExiting: this.OnGameExiting
);
StardewValley.Program.gamePtr = this.Game;
// apply game patches
new GamePatcher(this.Monitor).Apply(
new EventErrorPatch(this.LogManager.MonitorForGame),
new DialogueErrorPatch(this.LogManager.MonitorForGame, this.Reflection),
new ObjectErrorPatch(),
new LoadContextPatch(this.Reflection, this.OnLoadStageChanged),
new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved),
new ScheduleErrorPatch(this.LogManager.MonitorForGame)
);
// add exit handler
new Thread(() =>
{
this.CancellationToken.Token.WaitHandle.WaitOne();
if (this.IsGameRunning)
{
this.LogManager.WriteCrashLog();
this.Game.Exit();
}
}).Start();
// set window titles
this.SetWindowTitles(
game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}",
smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"
);
}
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.DeveloperMode, this.Settings.CheckForUpdates);
// set window titles
this.SetWindowTitles(
game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}",
smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"
);
// start game
this.Monitor.Log("Starting game...", LogLevel.Debug);
try
{
this.IsGameRunning = true;
StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window
this.Game.Run();
}
catch (Exception ex)
{
this.LogManager.LogFatalLaunchError(ex);
this.LogManager.PressAnyKeyToExit();
}
finally
{
this.Dispose();
}
}
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
public void Dispose()
{
// 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;
this.ContentCore?.Dispose();
this.CancellationToken?.Dispose();
this.Game?.Dispose();
this.LogManager?.Dispose(); // dispose last to allow for any last-second log messages
// end game (moved from Game1.OnExiting to let us clean up first)
Process.GetCurrentProcess().Kill();
}
/*********
** 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.CancellationToken.IsCancellationRequested)
{
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 ModToolkit();
ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath);
// load mods
{
this.Monitor.Log("Loading mod metadata...");
ModResolver resolver = new ModResolver();
// log loose files
{
string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray();
if (looseFiles.Any())
this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}");
}
// load manifests
IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).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();
// load mods
resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl);
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
// check for updates
this.CheckForUpdatesAsync(mods);
}
// update window titles
int modsLoaded = this.ModRegistry.GetAll().Count();
this.SetWindowTitles(
game: $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods",
smapi: $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"
);
}
/// Raised after the game finishes initializing.
private void OnGameInitialized()
{
// set initial state
this.Input.TrueUpdate();
// init watchers
this.Watchers = new WatcherCore(this.Input, this.Game.GetObservableLocations());
// 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
new Thread(
() => this.LogManager.RunConsoleInputLoop(
commandManager: this.CommandManager,
reloadTranslations: this.ReloadTranslations,
handleInput: input => this.CommandQueue.Enqueue(input),
continueWhile: () => this.IsGameRunning && !this.CancellationToken.IsCancellationRequested
)
).Start();
}
/// Raised after the game finishes loading its initial content.
private void OnGameContentLoaded()
{
// 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)
{
var events = this.EventManager;
try
{
/*********
** Safe queued work
*********/
// print warnings/alerts
SCore.DeprecationManager.PrintQueued();
SCore.PerformanceMonitor.PrintQueuedAlerts();
// reapply overrides
if (this.JustReturnedToTitle && !(Game1.mapDisplayDevice is SDisplayDevice))
Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice);
/*********
** First-tick initialization
*********/
if (!this.IsInitialized)
{
this.IsInitialized = true;
this.OnGameInitialized();
}
/*********
** Update input
*********/
// This should *always* run, even when suppressing mod events, since the game uses
// this too. For example, doing this after mod event suppression would prevent the
// user from doing anything on the overnight shipping screen.
SInputState inputState = this.Input;
if (this.Game.IsActive)
inputState.TrueUpdate();
/*********
** Special cases
*********/
// Abort if SMAPI is exiting.
if (this.CancellationToken.IsCancellationRequested)
{
this.Monitor.Log("SMAPI shutting down: aborting update.");
return;
}
// Run async tasks synchronously to avoid issues due to mod events triggering
// concurrently with game code.
bool saveParsed = false;
if (Game1.currentLoader != null)
{
this.Monitor.Log("Game loader synchronizing...");
while (Game1.currentLoader?.MoveNext() == true)
{
// raise load stage changed
switch (Game1.currentLoader.Current)
{
case 20 when (!saveParsed && SaveGame.loaded != null):
saveParsed = true;
this.OnLoadStageChanged(LoadStage.SaveParsed);
break;
case 36:
this.OnLoadStageChanged(LoadStage.SaveLoadedBasicInfo);
break;
case 50:
this.OnLoadStageChanged(LoadStage.SaveLoadedLocations);
break;
default:
if (Game1.gameMode == Game1.playingGameMode)
this.OnLoadStageChanged(LoadStage.Preloaded);
break;
}
}
Game1.currentLoader = null;
this.Monitor.Log("Game loader done.");
}
if (SGame.NewDayTask?.Status == TaskStatus.Created)
{
this.Monitor.Log("New day task synchronizing...");
SGame.NewDayTask.RunSynchronously();
this.Monitor.Log("New day task done.");
}
// While a background task is in progress, the game may make changes to the game
// state while mods are running their code. This is risky, because data changes can
// conflict (e.g. collection changed during enumeration errors) and data may change
// unexpectedly from one mod instruction to the next.
//
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are negligible and not worth the complications of bypassing Game1.Update.
if (SGame.NewDayTask != null || Game1.gameMode == Game1.loadingMode)
{
events.UnvalidatedUpdateTicking.RaiseEmpty();
SCore.TicksElapsed++;
runGameUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
// Raise minimal events while saving.
// While the game is writing to the save file in the background, mods can unexpectedly
// fail since they don't have exclusive access to resources (e.g. collection changed
// during enumeration errors). To avoid problems, events are not invoked while a save
// is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is
// opened (since the save hasn't started yet), but all other events should be suppressed.
if (Context.IsSaving)
{
// raise before-create
if (!Context.IsWorldReady && !this.IsBetweenCreateEvents)
{
this.IsBetweenCreateEvents = true;
this.Monitor.Log("Context: before save creation.");
events.SaveCreating.RaiseEmpty();
}
// raise before-save
if (Context.IsWorldReady && !this.IsBetweenSaveEvents)
{
this.IsBetweenSaveEvents = true;
this.Monitor.Log("Context: before save.");
events.Saving.RaiseEmpty();
}
// suppress non-save events
events.UnvalidatedUpdateTicking.RaiseEmpty();
SCore.TicksElapsed++;
runGameUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
/*********
** Reload assets when interceptors are added/removed
*********/
if (this.ReloadAssetInterceptorsQueue.Any())
{
// get unique interceptors
AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
.GroupBy(p => p.Instance, new ObjectReferenceComparer