summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework/SCore.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework/SCore.cs')
-rw-r--r--src/SMAPI/Framework/SCore.cs801
1 files changed, 754 insertions, 47 deletions
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 5bd02dc8..47a23c87 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -9,21 +10,31 @@ 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;
@@ -42,15 +53,18 @@ namespace StardewModdingAPI.Framework
/*********
** Fields
*********/
+ /****
+ ** Low-level components
+ ****/
+ /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary>
+ private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource();
+
/// <summary>Manages the SMAPI console window and log file.</summary>
private readonly LogManager LogManager;
/// <summary>The core logger and monitor for SMAPI.</summary>
private Monitor Monitor => this.LogManager.Monitor;
- /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary>
- private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource();
-
/// <summary>Simplifies access to private game code.</summary>
private readonly Reflector Reflection = new Reflector();
@@ -60,11 +74,26 @@ namespace StardewModdingAPI.Framework
/// <summary>The SMAPI configuration settings.</summary>
private readonly SConfig Settings;
+ /// <summary>The mod toolkit used for generic mod interactions.</summary>
+ private readonly ModToolkit Toolkit = new ModToolkit();
+
+ /****
+ ** Higher-level components
+ ****/
+ /// <summary>Manages console commands.</summary>
+ private readonly CommandManager CommandManager = new CommandManager();
+
/// <summary>The underlying game instance.</summary>
private SGame GameInstance;
- /// <summary>The underlying content manager.</summary>
- private ContentCoordinator ContentCore => this.GameInstance.ContentCore;
+ /// <summary>Manages input visible to the game.</summary>
+ private SInputState Input => SGame.Input;
+
+ /// <summary>The game's core multiplayer utility.</summary>
+ private SMultiplayer Multiplayer => SGame.Multiplayer;
+
+ /// <summary>SMAPI's content manager.</summary>
+ private ContentCoordinator ContentCore;
/// <summary>Tracks the installed mods.</summary>
/// <remarks>This is initialized after the game starts.</remarks>
@@ -73,17 +102,52 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages SMAPI events for mods.</summary>
private readonly EventManager EventManager;
+ /// <summary>Monitors the entire game state for changes.</summary>
+ private WatcherCore Watchers;
+
+ /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
+ private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
+
+ /****
+ ** State
+ ****/
+ /// <summary>The path to search for mods.</summary>
+ private string ModsPath => Constants.ModsPath;
+
/// <summary>Whether the game is currently running.</summary>
private bool IsGameRunning;
/// <summary>Whether the program has been disposed.</summary>
private bool IsDisposed;
- /// <summary>The mod toolkit used for generic mod interactions.</summary>
- private readonly ModToolkit Toolkit = new ModToolkit();
+ /// <summary>Whether the next content manager requested by the game will be for <see cref="Game1.content"/>.</summary>
+ private bool NextContentManagerIsMain;
- /// <summary>The path to search for mods.</summary>
- private string ModsPath => Constants.ModsPath;
+ /// <summary>Whether post-game-startup initialization has been performed.</summary>
+ private bool IsInitialized;
+
+ /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary>
+ private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second
+
+ /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary>
+ /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks>
+ private readonly Countdown AfterLoadTimer = new Countdown(5);
+
+ /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary>
+ private bool IsSaveContentRemoved;
+
+ /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary>
+ private bool IsBetweenSaveEvents;
+
+ /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary>
+ private bool IsBetweenCreateEvents;
+
+ /// <summary>Asset interceptors added or removed since the last tick.</summary>
+ private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
+
+ /// <summary>A list of queued commands to execute.</summary>
+ /// <remarks>This property must be thread-safe, since it's accessed from a separate console input thread.</remarks>
+ public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
/*********
@@ -97,6 +161,9 @@ namespace StardewModdingAPI.Framework
/// <remarks>This is initialized after the game starts. This is non-private for use by Console Commands.</remarks>
internal static PerformanceMonitor PerformanceMonitor { get; private set; }
+ /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary>
+ internal static uint TicksElapsed { get; private set; }
+
/*********
** Public methods
@@ -179,23 +246,24 @@ namespace StardewModdingAPI.Framework
LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged();
// override game
- SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded);
+ 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.GameInstance = new SGame(
monitor: this.Monitor,
- monitorForGame: this.LogManager.MonitorForGame,
reflection: this.Reflection,
- translator: this.Translator,
eventManager: this.EventManager,
- jsonHelper: this.Toolkit.JsonHelper,
- modRegistry: this.ModRegistry,
- deprecationManager: SCore.DeprecationManager,
- performanceMonitor: SCore.PerformanceMonitor,
- onGameInitialized: this.InitializeAfterGameStart,
- onGameExiting: this.Dispose,
- cancellationToken: this.CancellationToken,
- logNetworkTraffic: this.Settings.LogNetworkTraffic
+ modHooks: modHooks,
+ multiplayer: multiplayer,
+ exitGameImmediately: this.ExitGameImmediately
);
- this.Translator.SetLocale(this.GameInstance.ContentCore.GetLocale(), this.GameInstance.ContentCore.Language);
+
+ // hook game events
+ this.GameInstance.OnGameContentLoaded += this.OnLoadContent;
+ this.GameInstance.OnGameUpdating += this.OnGameUpdating;
+ this.GameInstance.OnGameExiting += this.OnGameExiting;
+
+ this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language);
StardewValley.Program.gamePtr = this.GameInstance;
// apply game patches
@@ -203,8 +271,8 @@ namespace StardewModdingAPI.Framework
new EventErrorPatch(this.LogManager.MonitorForGame),
new DialogueErrorPatch(this.LogManager.MonitorForGame, this.Reflection),
new ObjectErrorPatch(),
- new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged),
- new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved),
+ new LoadContextPatch(this.Reflection, this.OnLoadStageChanged),
+ new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved),
new ScheduleErrorPatch(this.LogManager.MonitorForGame)
);
@@ -345,9 +413,15 @@ namespace StardewModdingAPI.Framework
this.LogManager.SetConsoleTitle($"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods");
}
- /// <summary>Initialize SMAPI and mods after the game starts.</summary>
- private void InitializeAfterGameStart()
+ /// <summary>Raised after the game finishes initializing.</summary>
+ private void OnGameInitialized()
{
+ // set initial state
+ this.Input.TrueUpdate();
+
+ // init watchers
+ this.Watchers = new WatcherCore(this.Input);
+
// 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);
@@ -355,14 +429,557 @@ namespace StardewModdingAPI.Framework
// start SMAPI console
new Thread(
() => this.LogManager.RunConsoleInputLoop(
- commandManager: this.GameInstance.CommandManager,
+ commandManager: this.CommandManager,
reloadTranslations: this.ReloadTranslations,
- handleInput: input => this.GameInstance.CommandQueue.Enqueue(input),
+ handleInput: input => this.CommandQueue.Enqueue(input),
continueWhile: () => this.IsGameRunning && !this.CancellationToken.IsCancellationRequested
)
).Start();
}
+ /// <summary>Raised after the game finishes loading its initial content.</summary>
+ private void OnLoadContent()
+ {
+ // 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 ?? "<unknown>"}");
+#endif
+ }
+
+ /// <summary>Raised when the game is updating its state (roughly 60 times per second).</summary>
+ /// <param name="gameTime">A snapshot of the game timing state.</param>
+ /// <param name="runGameUpdate">Invoke the game's update logic.</param>
+ private void OnGameUpdating(GameTime gameTime, Action runGameUpdate)
+ {
+ var events = this.EventManager;
+
+ try
+ {
+ SCore.DeprecationManager.PrintQueued();
+ SCore.PerformanceMonitor.PrintQueuedAlerts();
+
+ /*********
+ ** 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.GameInstance.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<object>())
+ .Select(p => p.First())
+ .ToArray();
+ this.ReloadAssetInterceptorsQueue.Clear();
+
+ // log summary
+ this.Monitor.Log("Invalidating cached assets for new editors & loaders...");
+ this.Monitor.Log(
+ " changed: "
+ + string.Join(", ",
+ interceptors
+ .GroupBy(p => p.Mod)
+ .OrderBy(p => p.Key.DisplayName)
+ .Select(modGroup =>
+ $"{modGroup.Key.DisplayName} ("
+ + string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}"))
+ + ")"
+ )
+ )
+ );
+
+ // reload affected assets
+ this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset)));
+ }
+
+ /*********
+ ** Execute commands
+ *********/
+ while (this.CommandQueue.TryDequeue(out string rawInput))
+ {
+ // parse command
+ string name;
+ string[] args;
+ Command command;
+ try
+ {
+ if (!this.CommandManager.TryParse(rawInput, out name, out args, out command))
+ {
+ this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error);
+ continue;
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error);
+ continue;
+ }
+
+ // execute command
+ try
+ {
+ command.Callback.Invoke(name, args);
+ }
+ catch (Exception ex)
+ {
+ if (command.Mod != null)
+ command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
+ else
+ this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ /*********
+ ** Update context
+ *********/
+ bool wasWorldReady = Context.IsWorldReady;
+ if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle)
+ {
+ Context.IsWorldReady = false;
+ this.AfterLoadTimer.Reset();
+ }
+ else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null)
+ {
+ if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialized yet)
+ this.AfterLoadTimer.Decrement();
+ Context.IsWorldReady = this.AfterLoadTimer.Current == 0;
+ }
+
+ /*********
+ ** Update watchers
+ ** (Watchers need to be updated, checked, and reset in one go so we can detect any changes mods make in event handlers.)
+ *********/
+ this.Watchers.Update();
+ this.WatcherSnapshot.Update(this.Watchers);
+ this.Watchers.Reset();
+ WatcherSnapshot state = this.WatcherSnapshot;
+
+ /*********
+ ** Display in-game warnings
+ *********/
+ // save content removed
+ if (this.IsSaveContentRemoved && Context.IsWorldReady)
+ {
+ this.IsSaveContentRemoved = false;
+ Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type));
+ }
+
+ /*********
+ ** Pre-update events
+ *********/
+ {
+ /*********
+ ** Save created/loaded events
+ *********/
+ if (this.IsBetweenCreateEvents)
+ {
+ // raise after-create
+ this.IsBetweenCreateEvents = false;
+ this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.");
+ this.OnLoadStageChanged(LoadStage.CreatedSaveFile);
+ events.SaveCreated.RaiseEmpty();
+ }
+ if (this.IsBetweenSaveEvents)
+ {
+ // raise after-save
+ this.IsBetweenSaveEvents = false;
+ this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.");
+ events.Saved.RaiseEmpty();
+ events.DayStarted.RaiseEmpty();
+ }
+
+ /*********
+ ** Locale changed events
+ *********/
+ if (state.Locale.IsChanged)
+ this.Monitor.Log($"Context: locale set to {state.Locale.New}.");
+
+ /*********
+ ** Load / return-to-title events
+ *********/
+ if (wasWorldReady && !Context.IsWorldReady)
+ this.OnLoadStageChanged(LoadStage.None);
+ else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready)
+ {
+ // print context
+ string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}.";
+ if (Context.IsMultiplayer)
+ {
+ int onlineCount = Game1.getOnlineFarmers().Count();
+ context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online.";
+ }
+ else
+ context += " Single-player.";
+ this.Monitor.Log(context);
+
+ // raise events
+ this.OnLoadStageChanged(LoadStage.Ready);
+ events.SaveLoaded.RaiseEmpty();
+ events.DayStarted.RaiseEmpty();
+ }
+
+ /*********
+ ** Window events
+ *********/
+ // Here we depend on the game's viewport instead of listening to the Window.Resize
+ // event because we need to notify mods after the game handles the resize, so the
+ // game's metadata (like Game1.viewport) are updated. That's a bit complicated
+ // since the game adds & removes its own handler on the fly.
+ if (state.WindowSize.IsChanged)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}.");
+
+ events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New));
+ }
+
+ /*********
+ ** Input events (if window has focus)
+ *********/
+ if (this.GameInstance.IsActive)
+ {
+ // raise events
+ bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton));
+ if (!isChatInput)
+ {
+ ICursorPosition cursor = this.Input.CursorPosition;
+
+ // raise cursor moved event
+ if (state.Cursor.IsChanged)
+ events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New));
+
+ // raise mouse wheel scrolled
+ if (state.MouseWheelScroll.IsChanged)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}.");
+ events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New));
+ }
+
+ // raise input button events
+ foreach (var pair in inputState.LastButtonStates)
+ {
+ SButton button = pair.Key;
+ SButtonState status = pair.Value;
+
+ if (status == SButtonState.Pressed)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: button {button} pressed.");
+
+ events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState));
+ }
+ else if (status == SButtonState.Released)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: button {button} released.");
+
+ events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState));
+ }
+ }
+ }
+ }
+
+ /*********
+ ** Menu events
+ *********/
+ if (state.ActiveMenu.IsChanged)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Context: menu changed from {state.ActiveMenu.Old?.GetType().FullName ?? "none"} to {state.ActiveMenu.New?.GetType().FullName ?? "none"}.");
+
+ // raise menu events
+ events.MenuChanged.Raise(new MenuChangedEventArgs(state.ActiveMenu.Old, state.ActiveMenu.New));
+ }
+
+ /*********
+ ** World & player events
+ *********/
+ if (Context.IsWorldReady)
+ {
+ bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded
+
+ // location list changes
+ if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || this.Monitor.IsVerbose))
+ {
+ var added = state.Locations.LocationList.Added.ToArray();
+ var removed = state.Locations.LocationList.Removed.ToArray();
+
+ if (this.Monitor.IsVerbose)
+ {
+ string addedText = added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none";
+ string removedText = removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none";
+ this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).");
+ }
+
+ events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed));
+ }
+
+ // raise location contents changed
+ if (raiseWorldEvents)
+ {
+ foreach (LocationSnapshot locState in state.Locations.Locations)
+ {
+ var location = locState.Location;
+
+ // buildings changed
+ if (locState.Buildings.IsChanged)
+ events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, locState.Buildings.Added, locState.Buildings.Removed));
+
+ // debris changed
+ if (locState.Debris.IsChanged)
+ events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, locState.Debris.Added, locState.Debris.Removed));
+
+ // large terrain features changed
+ if (locState.LargeTerrainFeatures.IsChanged)
+ events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, locState.LargeTerrainFeatures.Added, locState.LargeTerrainFeatures.Removed));
+
+ // NPCs changed
+ if (locState.Npcs.IsChanged)
+ events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, locState.Npcs.Added, locState.Npcs.Removed));
+
+ // objects changed
+ if (locState.Objects.IsChanged)
+ events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed));
+
+ // chest items changed
+ if (events.ChestInventoryChanged.HasListeners())
+ {
+ foreach (var pair in locState.ChestItems)
+ {
+ SnapshotItemListDiff diff = pair.Value;
+ events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(pair.Key, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged));
+ }
+ }
+
+ // terrain features changed
+ if (locState.TerrainFeatures.IsChanged)
+ events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
+ }
+ }
+
+ // raise time changed
+ if (raiseWorldEvents && state.Time.IsChanged)
+ events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New));
+
+ // raise player events
+ if (raiseWorldEvents)
+ {
+ PlayerSnapshot playerState = state.CurrentPlayer;
+ Farmer player = playerState.Player;
+
+ // raise current location changed
+ if (playerState.Location.IsChanged)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Context: set location to {playerState.Location.New}.");
+
+ events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New));
+ }
+
+ // raise player leveled up a skill
+ foreach (var pair in playerState.Skills)
+ {
+ if (!pair.Value.IsChanged)
+ continue;
+
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}.");
+
+ events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New));
+ }
+
+ // raise player inventory changed
+ if (playerState.Inventory.IsChanged)
+ {
+ var inventory = playerState.Inventory;
+
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log("Events: player inventory changed.");
+ events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged));
+ }
+ }
+ }
+
+ /*********
+ ** Game update
+ *********/
+ // game launched
+ bool isFirstTick = SCore.TicksElapsed == 0;
+ if (isFirstTick)
+ {
+ Context.IsGameLaunched = true;
+ events.GameLaunched.Raise(new GameLaunchedEventArgs());
+ }
+
+ // preloaded
+ if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0)
+ this.OnLoadStageChanged(LoadStage.Loaded);
+ }
+
+ /*********
+ ** Game update tick
+ *********/
+ {
+ bool isOneSecond = SCore.TicksElapsed % 60 == 0;
+ events.UnvalidatedUpdateTicking.RaiseEmpty();
+ events.UpdateTicking.RaiseEmpty();
+ if (isOneSecond)
+ events.OneSecondUpdateTicking.RaiseEmpty();
+ try
+ {
+ this.Input.ApplyOverrides(); // if mods added any new overrides since the update, process them now
+ SCore.TicksElapsed++;
+ runGameUpdate();
+ }
+ catch (Exception ex)
+ {
+ this.LogManager.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error);
+ }
+
+ events.UnvalidatedUpdateTicked.RaiseEmpty();
+ events.UpdateTicked.RaiseEmpty();
+ if (isOneSecond)
+ events.OneSecondUpdateTicked.RaiseEmpty();
+ }
+
+ /*********
+ ** Update events
+ *********/
+ this.UpdateCrashTimer.Reset();
+ }
+ catch (Exception ex)
+ {
+ // log error
+ this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error);
+
+ // exit if irrecoverable
+ if (!this.UpdateCrashTimer.Decrement())
+ this.ExitGameImmediately("The game crashed when updating, and SMAPI was unable to recover the game.");
+ }
+ }
+
/// <summary>Handle the game changing locale.</summary>
private void OnLocaleChanged()
{
@@ -380,6 +997,96 @@ namespace StardewModdingAPI.Framework
mod.Translations.SetLocale(locale, languageCode);
}
+ /// <summary>Raised when the low-level stage while loading a save changes.</summary>
+ /// <param name="newStage">The new load stage.</param>
+ internal void OnLoadStageChanged(LoadStage newStage)
+ {
+ // nothing to do
+ if (newStage == Context.LoadStage)
+ return;
+
+ // update data
+ LoadStage oldStage = Context.LoadStage;
+ Context.LoadStage = newStage;
+ this.Monitor.VerboseLog($"Context: load stage changed to {newStage}");
+ if (newStage == LoadStage.None)
+ {
+ this.Monitor.Log("Context: returned to title");
+ this.OnReturnedToTitle();
+ }
+
+ // raise events
+ this.EventManager.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage));
+ if (newStage == LoadStage.None)
+ this.EventManager.ReturnedToTitle.RaiseEmpty();
+ }
+
+ /// <summary>Raised after custom content is removed from the save data to avoid a crash.</summary>
+ internal void OnSaveContentRemoved()
+ {
+ this.IsSaveContentRemoved = true;
+ }
+
+ /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary>
+ protected void OnNewDayAfterFade()
+ {
+ this.EventManager.DayEnding.RaiseEmpty();
+ }
+
+ /// <summary>Raised after the player returns to the title screen.</summary>
+ private void OnReturnedToTitle()
+ {
+ // perform cleanup
+ this.Multiplayer.CleanupOnMultiplayerExit();
+ if (!(Game1.mapDisplayDevice is SDisplayDevice))
+ Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice);
+ }
+
+ /// <summary>Raised before the game exits.</summary>
+ private void OnGameExiting()
+ {
+ this.Multiplayer.Disconnect(StardewValley.Multiplayer.DisconnectType.ClosedGame);
+ this.Dispose();
+ }
+
+ /// <summary>Raised when a mod network message is received.</summary>
+ /// <param name="message">The message to deliver to applicable mods.</param>
+ private void OnModMessageReceived(ModMessageModel message)
+ {
+ // get mod IDs to notify
+ HashSet<string> modIDs = new HashSet<string>(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.OrdinalIgnoreCase);
+ if (message.FromPlayerID == Game1.player?.UniqueMultiplayerID)
+ modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender
+
+ // raise events
+ this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID));
+ }
+
+ /// <summary>Constructor a content manager to read game content files.</summary>
+ /// <param name="serviceProvider">The service provider to use to locate services.</param>
+ /// <param name="rootDirectory">The root directory to search for content.</param>
+ private LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory)
+ {
+ // Game1._temporaryContent initializing from SGame constructor
+ // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialized at this point.
+ if (this.ContentCore == null)
+ {
+ this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded);
+ this.NextContentManagerIsMain = true;
+ return this.ContentCore.CreateGameContentManager("Game1._temporaryContent");
+ }
+
+ // Game1.content initializing from LoadContent
+ if (this.NextContentManagerIsMain)
+ {
+ this.NextContentManagerIsMain = false;
+ return this.ContentCore.MainContentManager;
+ }
+
+ // any other content manager
+ return this.ContentCore.CreateGameContentManager("(generated)");
+ }
+
/// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary>
/// <returns>Returns whether all integrity checks passed.</returns>
private bool ValidateContentIntegrity()
@@ -635,8 +1342,8 @@ namespace StardewModdingAPI.Framework
this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader));
// ReSharper restore SuspiciousTypeConversion.Global
- helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors);
- helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders);
+ helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors);
+ helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders);
}
// call entry method
@@ -670,34 +1377,27 @@ namespace StardewModdingAPI.Framework
}
}
- // invalidate cache entries when needed
- // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialize.)
- foreach (IModMetadata metadata in loadedMods)
- {
- if (metadata.Mod.Helper.Content is ContentHelper helper)
- {
- helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
- helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
- }
- }
-
// unlock mod integrations
this.ModRegistry.AreAllModsInitialized = true;
}
- /// <summary>Handle a mod adding or removing asset interceptors.</summary>
+ /// <summary>Raised after a mod adds or removes asset interceptors.</summary>
/// <typeparam name="T">The asset interceptor type (one of <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/>).</typeparam>
/// <param name="mod">The mod metadata.</param>
/// <param name="added">The interceptors that were added.</param>
/// <param name="removed">The interceptors that were removed.</param>
- /// <param name="list">The list to update.</param>
- private void OnInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list)
+ /// <param name="list">A list of interceptors to update for the change.</param>
+ private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list)
{
foreach (T interceptor in added ?? new T[0])
+ {
+ this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: true));
list.Add(new ModLinked<T>(mod, interceptor));
+ }
foreach (T interceptor in removed ?? new T[0])
{
+ this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: false));
foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray())
list.Remove(entry);
}
@@ -841,15 +1541,15 @@ namespace StardewModdingAPI.Framework
}
IModEvents events = new ModEvents(mod, this.EventManager);
- ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager);
+ ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager);
IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor);
IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack);
IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper);
IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection);
IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
- IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer);
+ IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.Multiplayer);
- modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
+ modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
}
// init mod
@@ -969,7 +1669,6 @@ namespace StardewModdingAPI.Framework
catch (Exception ex)
{
errors.Add($"{file.Name} file couldn't be parsed: {ex.GetLogSummary()}");
- continue;
}
}
}
@@ -1045,5 +1744,13 @@ namespace StardewModdingAPI.Framework
}
}
}
+
+ /// <summary>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.</summary>
+ /// <param name="message">The fatal log message.</param>
+ private void ExitGameImmediately(string message)
+ {
+ this.Monitor.LogFatal(message);
+ this.CancellationToken.Cancel();
+ }
}
}