summaryrefslogtreecommitdiff
path: root/src/SMAPI
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI')
-rw-r--r--src/SMAPI/Constants.cs15
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs10
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs31
-rw-r--r--src/SMAPI/Framework/ContentPack.cs4
-rw-r--r--src/SMAPI/Framework/ModHelpers/ContentHelper.cs2
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModHelper.cs4
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs10
-rw-r--r--src/SMAPI/Framework/SGame.cs26
-rw-r--r--src/SMAPI/Framework/SGameConstructorHack.cs37
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs84
-rw-r--r--src/SMAPI/Program.cs198
-rw-r--r--src/SMAPI/SButton.cs6
-rw-r--r--src/SMAPI/StardewModdingAPI.csproj1
13 files changed, 299 insertions, 129 deletions
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index a6cddbe4..bd512fb1 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -29,10 +29,10 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.6.0");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.7.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.27");
+ public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.28");
/// <summary>The maximum supported version of Stardew Valley.</summary>
public static ISemanticVersion MaximumGameVersion { get; } = null;
@@ -70,11 +70,14 @@ namespace StardewModdingAPI
/// <summary>The file path for the SMAPI metadata file.</summary>
internal static string ApiMetadataPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.metadata.json");
- /// <summary>The filename prefix for SMAPI log files.</summary>
- internal static string LogNamePrefix { get; } = "SMAPI-latest";
+ /// <summary>The filename prefix used for all SMAPI logs.</summary>
+ internal static string LogNamePrefix { get; } = "SMAPI-";
+
+ /// <summary>The filename for SMAPI's main log, excluding the <see cref="LogExtension"/>.</summary>
+ internal static string LogFilename { get; } = $"{Constants.LogNamePrefix}latest";
/// <summary>The filename extension for SMAPI log files.</summary>
- internal static string LogNameExtension { get; } = "txt";
+ internal static string LogExtension { get; } = "txt";
/// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary>
internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt");
@@ -86,7 +89,7 @@ namespace StardewModdingAPI
internal static string UpdateMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.update.marker");
/// <summary>The full path to the folder containing mods.</summary>
- internal static string ModPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods");
+ internal static string DefaultModsPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods");
/// <summary>The game's current semantic version.</summary>
internal static ISemanticVersion GameVersion { get; } = new GameVersion(Constants.GetGameVersion());
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index d9b2109a..9eb7b5f9 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -9,6 +9,7 @@ using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Metadata;
+using StardewModdingAPI.Toolkit.Serialisation;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
@@ -32,6 +33,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Simplifies access to private code.</summary>
private readonly Reflector Reflection;
+ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
+ private readonly JsonHelper JsonHelper;
+
/// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
private readonly IList<IContentManager> ContentManagers = new List<IContentManager>();
@@ -67,10 +71,12 @@ namespace StardewModdingAPI.Framework
/// <param name="currentCulture">The current culture for which to localise content.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
/// <param name="reflection">Simplifies access to private code.</param>
- public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection)
+ /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
+ public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper)
{
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
+ this.JsonHelper = jsonHelper;
this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing)
@@ -92,7 +98,7 @@ namespace StardewModdingAPI.Framework
/// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param>
public ModContentManager CreateModContentManager(string name, string rootDirectory)
{
- ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing);
+ ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.JsonHelper, this.OnDisposing);
this.ContentManagers.Add(manager);
return manager;
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 80bf37e9..24ce69ea 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -5,6 +5,7 @@ using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Toolkit.Serialisation;
using StardewValley;
namespace StardewModdingAPI.Framework.ContentManagers
@@ -13,6 +14,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
internal class ModContentManager : BaseContentManager
{
/*********
+ ** Properties
+ *********/
+ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
+ private readonly JsonHelper JsonHelper;
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
@@ -23,9 +31,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="coordinator">The central coordinator which manages content managers.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
/// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param>
- public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing)
- : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) { }
+ public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing)
+ : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true)
+ {
+ this.JsonHelper = jsonHelper;
+ }
/// <summary>Load an asset that has been processed by the content pipeline.</summary>
/// <typeparam name="T">The type of asset to load.</typeparam>
@@ -95,9 +107,14 @@ namespace StardewModdingAPI.Framework.ContentManagers
case ".xnb":
return base.Load<T>(relativePath, language);
- // unpacked map
- case ".tbin":
- throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper.");
+ // unpacked data
+ case ".json":
+ {
+ if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data))
+ throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above
+
+ return data;
+ }
// unpacked image
case ".png":
@@ -114,6 +131,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
return (T)(object)texture;
}
+ // unpacked map
+ case ".tbin":
+ throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper.");
+
default:
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'.");
}
diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs
index 4a4adb90..62d8b80d 100644
--- a/src/SMAPI/Framework/ContentPack.cs
+++ b/src/SMAPI/Framework/ContentPack.cs
@@ -54,7 +54,9 @@ namespace StardewModdingAPI.Framework
public TModel ReadJsonFile<TModel>(string path) where TModel : class
{
path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path));
- return this.JsonHelper.ReadJsonFile<TModel>(path);
+ return this.JsonHelper.ReadJsonFileIfExists(path, out TModel model)
+ ? model
+ : null;
}
/// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
index 671dc21e..a8b24a13 100644
--- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs
@@ -259,7 +259,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
// get seasonal name (if applicable)
string seasonalImageSource = null;
- if (Game1.currentSeason != null)
+ if (Context.IsSaveLoaded && Game1.currentSeason != null)
{
string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null.");
bool hasSeasonalPrefix =
diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
index d9498e83..0ba258b4 100644
--- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
@@ -138,7 +138,9 @@ namespace StardewModdingAPI.Framework.ModHelpers
where TModel : class
{
path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path));
- return this.JsonHelper.ReadJsonFile<TModel>(path);
+ return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data)
+ ? data
+ : null;
}
/// <summary>Save to a JSON file.</summary>
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
index cf5a3175..3a26660f 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
@@ -70,7 +70,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
// method reference
MethodReference methodReference = RewriteHelper.AsMethodReference(instruction);
- if (methodReference != null && this.ShouldValidate(methodReference.DeclaringType))
+ if (methodReference != null && !this.IsUnsupported(methodReference) && this.ShouldValidate(methodReference.DeclaringType))
{
// get potential targets
MethodDefinition[] candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray();
@@ -106,6 +106,14 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name);
}
+ /// <summary>Get whether a method reference is a special case that's not currently supported (e.g. array methods).</summary>
+ /// <param name="method">The method reference.</param>
+ private bool IsUnsupported(MethodReference method)
+ {
+ return
+ method.DeclaringType.Name.Contains("["); // array methods
+ }
+
/// <summary>Get a shorter type name for display.</summary>
/// <param name="type">The type reference.</param>
private string GetFriendlyTypeName(TypeReference type)
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 05fedc3d..83e8c9a7 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -17,6 +17,7 @@ using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking;
using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Toolkit.Serialisation;
using StardewValley;
using StardewValley.BellsAndWhistles;
using StardewValley.Buildings;
@@ -37,16 +38,6 @@ namespace StardewModdingAPI.Framework
** Properties
*********/
/****
- ** Constructor hack
- ****/
- /// <summary>A static instance of <see cref="Monitor"/> to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary>
- internal static IMonitor MonitorDuringInitialisation;
-
- /// <summary>A static instance of <see cref="Reflection"/> to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary>
- internal static Reflector ReflectorDuringInitialisation;
-
-
- /****
** SMAPI state
****/
/// <summary>Encapsulates monitoring and logging.</summary>
@@ -83,6 +74,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Simplifies access to private game code.</summary>
private readonly Reflector Reflection;
+ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
+ private readonly JsonHelper JsonHelper;
+
/****
** Game state
****/
@@ -105,6 +99,9 @@ namespace StardewModdingAPI.Framework
/*********
** Accessors
*********/
+ /// <summary>Static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary>
+ internal static SGameConstructorHack ConstructorHack { get; set; }
+
/// <summary>SMAPI's content manager.</summary>
public ContentCoordinator ContentCore { get; private set; }
@@ -132,10 +129,13 @@ namespace StardewModdingAPI.Framework
/// <param name="monitor">Encapsulates monitoring and logging.</param>
/// <param name="reflection">Simplifies access to private game code.</param>
/// <param name="eventManager">Manages SMAPI events for mods.</param>
+ /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param>
/// <param name="onGameExiting">A callback to invoke when the game exits.</param>
- internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, Action onGameInitialised, Action onGameExiting)
+ internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, Action onGameInitialised, Action onGameExiting)
{
+ SGame.ConstructorHack = null;
+
// check expectations
if (this.ContentCore == null)
throw new InvalidOperationException($"The game didn't initialise its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change.");
@@ -147,6 +147,7 @@ namespace StardewModdingAPI.Framework
this.Monitor = monitor;
this.Events = eventManager;
this.Reflection = reflection;
+ this.JsonHelper = jsonHelper;
this.OnGameInitialised = onGameInitialised;
this.OnGameExiting = onGameExiting;
Game1.input = new SInputState();
@@ -191,8 +192,7 @@ namespace StardewModdingAPI.Framework
// NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialised at this point.
if (this.ContentCore == null)
{
- this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation);
- SGame.MonitorDuringInitialisation = null;
+ this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper);
this.NextContentManagerIsMain = true;
return this.ContentCore.CreateGameContentManager("Game1._temporaryContent");
}
diff --git a/src/SMAPI/Framework/SGameConstructorHack.cs b/src/SMAPI/Framework/SGameConstructorHack.cs
new file mode 100644
index 00000000..494bab99
--- /dev/null
+++ b/src/SMAPI/Framework/SGameConstructorHack.cs
@@ -0,0 +1,37 @@
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>The static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary>
+ internal class SGameConstructorHack
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ public IMonitor Monitor { get; }
+
+ /// <summary>Simplifies access to private game code.</summary>
+ public Reflector Reflection { get; }
+
+ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
+ public JsonHelper JsonHelper { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private game code.</param>
+ /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
+ public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper)
+ {
+ this.Monitor = monitor;
+ this.Reflection = reflection;
+ this.JsonHelper = jsonHelper;
+ }
+ }
+}
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index 12abeb10..8487b6be 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -13,6 +13,7 @@ using StardewValley.Menus;
using StardewValley.Objects;
using StardewValley.Projectiles;
using StardewValley.TerrainFeatures;
+using xTile.Tiles;
namespace StardewModdingAPI.Metadata
{
@@ -63,6 +64,23 @@ namespace StardewModdingAPI.Metadata
/// <returns>Returns any non-null value to indicate an asset was loaded.</returns>
private object PropagateImpl(LocalizedContentManager content, string key)
{
+ /****
+ ** Special case: current map tilesheet
+ ** We only need to do this for the current location, since tilesheets are reloaded when you enter a location.
+ ** Just in case, we should still propagate by key even if a tilesheet is matched.
+ ****/
+ if (Game1.currentLocation?.map?.TileSheets != null)
+ {
+ foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets)
+ {
+ if (this.GetNormalisedPath(tilesheet.ImageSource) == key)
+ Game1.mapDisplayDevice.LoadTileSheet(tilesheet);
+ }
+ }
+
+ /****
+ ** Propagate by key
+ ****/
Reflector reflection = this.Reflection;
switch (key.ToLower().Replace("/", "\\")) // normalised key so we can compare statically
{
@@ -313,21 +331,21 @@ namespace StardewModdingAPI.Metadata
if (this.IsInFolder(key, "Buildings"))
return this.ReloadBuildings(content, key);
- if (this.IsInFolder(key, "Characters"))
- return this.ReloadNpcSprites(content, key, monster: false);
-
- if (this.IsInFolder(key, "Characters\\Monsters"))
- return this.ReloadNpcSprites(content, key, monster: true);
+ if (this.IsInFolder(key, "Characters") || this.IsInFolder(key, "Characters\\Monsters"))
+ return this.ReloadNpcSprites(content, key);
- if (key.StartsWith(this.GetNormalisedPath("LooseSprites\\Fence"), StringComparison.InvariantCultureIgnoreCase))
- return this.ReloadFenceTextures(content, key);
+ if (this.KeyStartsWith(key, "LooseSprites\\Fence"))
+ return this.ReloadFenceTextures(key);
if (this.IsInFolder(key, "Portraits"))
return this.ReloadNpcPortraits(content, key);
// dynamic data
+ if (this.IsInFolder(key, "Characters\\Dialogue"))
+ return this.ReloadNpcDialogue(key);
+
if (this.IsInFolder(key, "Characters\\schedules"))
- return this.ReloadNpcSchedules(content, key);
+ return this.ReloadNpcSchedules(key);
return false;
}
@@ -416,10 +434,9 @@ namespace StardewModdingAPI.Metadata
}
/// <summary>Reload the sprites for a fence type.</summary>
- /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadFenceTextures(LocalizedContentManager content, string key)
+ private bool ReloadFenceTextures(string key)
{
// get fence type
if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType))
@@ -446,13 +463,13 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
- /// <param name="monster">Whether to match monsters (<c>true</c>) or non-monsters (<c>false</c>).</param>
/// <returns>Returns whether any textures were reloaded.</returns>
- private bool ReloadNpcSprites(LocalizedContentManager content, string key, bool monster)
+ private bool ReloadNpcSprites(LocalizedContentManager content, string key)
{
// get NPCs
- string name = this.GetNpcNameFromFileName(Path.GetFileName(key));
- NPC[] characters = this.GetCharacters().Where(npc => npc.Name == name && npc.IsMonster == monster).ToArray();
+ NPC[] characters = this.GetCharacters()
+ .Where(npc => this.GetNormalisedPath(npc.Sprite.textureName.Value) == key)
+ .ToArray();
if (!characters.Any())
return false;
@@ -470,15 +487,20 @@ namespace StardewModdingAPI.Metadata
private bool ReloadNpcPortraits(LocalizedContentManager content, string key)
{
// get NPCs
- string name = this.GetNpcNameFromFileName(Path.GetFileName(key));
- NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray();
+ NPC[] villagers = this.GetCharacters()
+ .Where(npc => npc.isVillager() && this.GetNormalisedPath($"Portraits\\{this.Reflection.GetMethod(npc, "getTextureName").Invoke<string>()}") == key)
+ .ToArray();
if (!villagers.Any())
return false;
// update portrait
Texture2D texture = content.Load<Texture2D>(key);
foreach (NPC villager in villagers)
+ {
+ villager.resetPortrait();
villager.Portrait = texture;
+ }
+
return true;
}
@@ -508,11 +530,27 @@ namespace StardewModdingAPI.Metadata
/****
** Reload data methods
****/
+ /// <summary>Reload the dialogue data for matching NPCs.</summary>
+ /// <param name="key">The asset key to reload.</param>
+ /// <returns>Returns whether any assets were reloaded.</returns>
+ private bool ReloadNpcDialogue(string key)
+ {
+ // get NPCs
+ string name = Path.GetFileName(key);
+ NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray();
+ if (!villagers.Any())
+ return false;
+
+ // update dialogue
+ foreach (NPC villager in villagers)
+ villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue
+ return true;
+ }
+
/// <summary>Reload the schedules for matching NPCs.</summary>
- /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
/// <returns>Returns whether any assets were reloaded.</returns>
- private bool ReloadNpcSchedules(LocalizedContentManager content, string key)
+ private bool ReloadNpcSchedules(string key)
{
// get NPCs
string name = Path.GetFileName(key);
@@ -607,6 +645,14 @@ namespace StardewModdingAPI.Metadata
}
}
+ /// <summary>Get whether a key starts with a substring after the substring is normalised.</summary>
+ /// <param name="key">The key to check.</param>
+ /// <param name="rawSubstring">The substring to normalise and find.</param>
+ private bool KeyStartsWith(string key, string rawSubstring)
+ {
+ return key.StartsWith(this.GetNormalisedPath(rawSubstring), StringComparison.InvariantCultureIgnoreCase);
+ }
+
/// <summary>Get whether a normalised asset key is in the given folder.</summary>
/// <param name="key">The normalised asset key (like <c>Animals/cat</c>).</param>
/// <param name="folder">The key folder (like <c>Animals</c>); doesn't need to be normalised.</param>
@@ -614,7 +660,7 @@ namespace StardewModdingAPI.Metadata
private bool IsInFolder(string key, string folder, bool allowSubfolders = false)
{
return
- key.StartsWith(this.GetNormalisedPath($"{folder}\\"), StringComparison.InvariantCultureIgnoreCase)
+ this.KeyStartsWith(key, $"{folder}\\")
&& (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1);
}
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index 6012b15a..634c5066 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -11,7 +11,6 @@ using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
-using Microsoft.Xna.Framework.Input;
#if SMAPI_FOR_WINDOWS
using System.Windows.Forms;
#endif
@@ -32,10 +31,8 @@ using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Serialisation;
-using StardewModdingAPI.Toolkit.Serialisation.Converters;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
-using Keys = Microsoft.Xna.Framework.Input.Keys;
using Monitor = StardewModdingAPI.Framework.Monitor;
using SObject = StardewValley.Object;
using ThreadState = System.Threading.ThreadState;
@@ -103,6 +100,9 @@ namespace StardewModdingAPI
/// <summary>The mod toolkit used for generic mod interactions.</summary>
private readonly ModToolkit Toolkit = new ModToolkit();
+ /// <summary>The path to search for mods.</summary>
+ private readonly string ModsPath;
+
/*********
** Public methods
@@ -116,18 +116,34 @@ namespace StardewModdingAPI
// get flags from arguments
bool writeToConsole = !args.Contains("--no-terminal");
+ // get mods path from arguments
+ string modsPath = null;
+ {
+ int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1;
+ if (pathIndex >= 1 && args.Length >= pathIndex)
+ {
+ modsPath = args[pathIndex];
+ if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath))
+ modsPath = Path.Combine(Constants.ExecutionPath, modsPath);
+ }
+ if (string.IsNullOrWhiteSpace(modsPath))
+ modsPath = Constants.DefaultModsPath;
+ }
+
// load SMAPI
- using (Program program = new Program(writeToConsole))
+ using (Program program = new Program(modsPath, writeToConsole))
program.RunInteractively();
}
/// <summary>Construct an instance.</summary>
+ /// <param name="modsPath">The path to search for mods.</param>
/// <param name="writeToConsole">Whether to output log messages to the console.</param>
- public Program(bool writeToConsole)
+ public Program(string modsPath, bool writeToConsole)
{
// init paths
- this.VerifyPath(Constants.ModPath);
+ this.VerifyPath(modsPath);
this.VerifyPath(Constants.LogDir);
+ this.ModsPath = modsPath;
// init log file
this.PurgeLogFiles();
@@ -146,7 +162,9 @@ namespace StardewModdingAPI
// init logging
this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
- this.Monitor.Log($"Mods go here: {Constants.ModPath}");
+ this.Monitor.Log($"Mods go here: {modsPath}");
+ if (modsPath != Constants.DefaultModsPath)
+ this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace);
this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace);
// validate game version
@@ -193,9 +211,6 @@ namespace StardewModdingAPI
// init JSON parser
JsonConverter[] converters = {
- new StringEnumConverter<Buttons>(),
- new StringEnumConverter<Keys>(),
- new StringEnumConverter<SButton>(),
new ColorConverter(),
new PointConverter(),
new RectangleConverter()
@@ -214,9 +229,8 @@ namespace StardewModdingAPI
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
// override game
- SGame.MonitorDuringInitialisation = this.Monitor;
- SGame.ReflectorDuringInitialisation = this.Reflection;
- this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose);
+ SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper);
+ this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.InitialiseAfterGameStart, this.Dispose);
StardewValley.Program.gamePtr = this.GameInstance;
// add exit handler
@@ -418,7 +432,7 @@ namespace StardewModdingAPI
ModResolver resolver = new ModResolver();
// load manifests
- IModMetadata[] mods = resolver.ReadManifests(toolkit, Constants.ModPath, modDatabase).ToArray();
+ IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray();
resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl);
// process dependencies
@@ -435,10 +449,10 @@ namespace StardewModdingAPI
Exported = DateTime.UtcNow.ToString("O"),
ApiVersion = Constants.ApiVersion.ToString(),
GameVersion = Constants.GameVersion.ToString(),
- ModFolderPath = Constants.ModPath,
+ ModFolderPath = this.ModsPath,
Mods = mods
};
- this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.metadata-dump.json"), export);
+ this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export);
}
// check for updates
@@ -746,7 +760,7 @@ namespace StardewModdingAPI
// load content packs
foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack))
{
- this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)})...", LogLevel.Trace);
+ this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)})...", LogLevel.Trace);
// show warning for missing update key
if (metadata.HasManifest() && !metadata.HasUpdateKeys())
@@ -791,7 +805,7 @@ namespace StardewModdingAPI
// get basic info
IManifest manifest = metadata.Manifest;
this.Monitor.Log(metadata.Manifest?.EntryDll != null
- ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid
+ ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid
: $" {metadata.DisplayName}...", LogLevel.Trace);
// show warnings
@@ -884,33 +898,15 @@ namespace StardewModdingAPI
}
IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray();
- // log skipped mods
- this.Monitor.Newline();
- if (skippedMods.Any())
- {
- this.Monitor.Log($"Skipped {skippedMods.Count} mods:", LogLevel.Error);
- foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
- {
- IModMetadata mod = pair.Key;
- string[] reason = pair.Value;
-
- this.Monitor.Log($" {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {reason[0]}", LogLevel.Error);
- if (reason[1] != null)
- this.Monitor.Log($" {reason[1]}", LogLevel.Trace);
- }
- this.Monitor.Newline();
- }
-
// log loaded mods
this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info);
-
foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName))
{
IManifest manifest = metadata.Manifest;
this.Monitor.Log(
$" {metadata.DisplayName} {manifest.Version}"
- + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
- + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
+ + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
+ + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
LogLevel.Info
);
}
@@ -936,27 +932,8 @@ namespace StardewModdingAPI
this.Monitor.Newline();
}
- // log warnings
- {
- IModMetadata[] modsWithWarnings = this.ModRegistry.GetAll().Where(p => p.Warnings != ModWarning.None).ToArray();
- if (modsWithWarnings.Any())
- {
- this.Monitor.Log($"Found issues with {modsWithWarnings.Length} mods:", LogLevel.Warn);
- foreach (IModMetadata metadata in modsWithWarnings)
- {
- string[] warnings = this.GetWarningText(metadata.Warnings).ToArray();
- if (warnings.Length == 1)
- this.Monitor.Log($" {metadata.DisplayName} {warnings[0]}", LogLevel.Warn);
- else
- {
- this.Monitor.Log($" {metadata.DisplayName}:", LogLevel.Warn);
- foreach (string warning in warnings)
- this.Monitor.Log(" - " + warning, LogLevel.Warn);
- }
- }
- this.Monitor.Newline();
- }
- }
+ // log mod warnings
+ this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods);
// initialise translations
this.ReloadTranslations(loadedMods);
@@ -1047,23 +1024,87 @@ namespace StardewModdingAPI
this.ModRegistry.AreAllModsInitialised = true;
}
- /// <summary>Get the warning text for a mod warning bit mask.</summary>
- /// <param name="mask">The mod warning bit mask.</param>
- private IEnumerable<string> GetWarningText(ModWarning mask)
+ /// <summary>Write a summary of mod warnings to the console and log.</summary>
+ /// <param name="mods">The loaded mods.</param>
+ /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param>
+ private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, string[]> skippedMods)
{
- if (mask.HasFlag(ModWarning.BrokenCodeLoaded))
- yield return "has broken code, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.";
- if (mask.HasFlag(ModWarning.ChangesSaveSerialiser))
- yield return "accesses the save serialiser and may break your saves.";
- if (mask.HasFlag(ModWarning.PatchesGame))
- yield return "patches the game. This may cause errors or bugs in-game. If you have issues, try removing this mod first.";
- if (mask.HasFlag(ModWarning.UsesUnvalidatedUpdateTick))
- yield return "bypasses normal SMAPI event protections. This may cause errors or save corruption. If you have issues, try removing this mod first.";
- if (mask.HasFlag(ModWarning.UsesDynamic))
- yield return "uses the 'dynamic' keyword. This won't work on Linux/Mac.";
- if (mask.HasFlag(ModWarning.NoUpdateKeys))
- yield return "has no update keys in its manifest. SMAPI won't show update alerts for this mod.";
+ // get mods with warnings
+ IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray();
+ if (!modsWithWarnings.Any() && !skippedMods.Any())
+ return;
+
+ // log intro
+ {
+ int count = modsWithWarnings.Union(skippedMods.Keys).Count();
+ this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info);
+ }
+
+ // log skipped mods
+ if (skippedMods.Any())
+ {
+ this.Monitor.Log(" Skipped mods", LogLevel.Error);
+ this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error);
+ this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error);
+ this.Monitor.Newline();
+ foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
+ {
+ IModMetadata mod = pair.Key;
+ string[] reason = pair.Value;
+
+ this.Monitor.Log($" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {reason[0]}", LogLevel.Error);
+ if (reason[1] != null)
+ this.Monitor.Log($" ({reason[1]})", LogLevel.Trace);
+ }
+ this.Monitor.Newline();
+ }
+
+ // log warnings
+ if (modsWithWarnings.Any())
+ {
+ // issue block format logic
+ void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb)
+ {
+ IModMetadata[] matches = modsWithWarnings.Where(p => p.Warnings.HasFlag(warning)).ToArray();
+ if (!matches.Any())
+ return;
+
+ this.Monitor.Log(" " + heading, logLevel);
+ this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel);
+ foreach (string line in blurb)
+ this.Monitor.Log(" " + line, logLevel);
+ this.Monitor.Newline();
+ foreach (IModMetadata match in matches)
+ this.Monitor.Log($" - {match.DisplayName}", logLevel);
+ this.Monitor.Newline();
+ }
+
+ // supported issues
+ LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods",
+ "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,",
+ "errors, or crashes in-game."
+ );
+ LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser",
+ "These mods change the save serialiser. They may corrupt your save files, or make them unusable if",
+ "you uninstall these mods."
+ );
+ LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code",
+ "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if",
+ "your game has issues, try removing these first. Otherwise you can ignore this warning."
+ );
+ LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks",
+ "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save",
+ "corruption. If your game has issues, try removing these first."
+ );
+ LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys",
+ "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these",
+ "mods. Consider notifying the mod authors about this problem."
+ );
+ LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform",
+ "These mods use the 'dynamic' keyword, and won't work on Linux/Mac."
+ );
+ }
}
/// <summary>Load a mod's entry class.</summary>
@@ -1118,7 +1159,10 @@ namespace StardewModdingAPI
string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim());
try
{
- translations[locale] = jsonHelper.ReadJsonFile<IDictionary<string, string>>(file.FullName);
+ if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data))
+ translations[locale] = data;
+ else
+ metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.");
}
catch (Exception ex)
{
@@ -1260,7 +1304,7 @@ namespace StardewModdingAPI
{
// default path
{
- FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.{Constants.LogNameExtension}"));
+ FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}"));
if (!defaultFile.Exists)
return defaultFile.FullName;
}
@@ -1268,7 +1312,7 @@ namespace StardewModdingAPI
// get first disambiguated path
for (int i = 2; i < int.MaxValue; i++)
{
- FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.player-{i}.{Constants.LogNameExtension}"));
+ FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}"));
if (!file.Exists)
return file.FullName;
}
diff --git a/src/SMAPI/SButton.cs b/src/SMAPI/SButton.cs
index 3f95169a..bc76c91d 100644
--- a/src/SMAPI/SButton.cs
+++ b/src/SMAPI/SButton.cs
@@ -604,21 +604,21 @@ namespace StardewModdingAPI
*********/
/// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary>
/// <param name="key">The keyboard button to convert.</param>
- internal static SButton ToSButton(this Keys key)
+ public static SButton ToSButton(this Keys key)
{
return (SButton)key;
}
/// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary>
/// <param name="key">The controller button to convert.</param>
- internal static SButton ToSButton(this Buttons key)
+ public static SButton ToSButton(this Buttons key)
{
return (SButton)(SButtonExtensions.ControllerOffset + key);
}
/// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary>
/// <param name="input">The Stardew Valley button to convert.</param>
- internal static SButton ToSButton(this InputButton input)
+ public static SButton ToSButton(this InputButton input)
{
// derived from InputButton constructors
if (input.mouseLeft)
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index 0d0a5fe9..fc2d45ba 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -109,6 +109,7 @@
<Compile Include="Events\WorldBuildingListChangedEventArgs.cs" />
<Compile Include="Events\WorldLocationListChangedEventArgs.cs" />
<Compile Include="Events\WorldObjectListChangedEventArgs.cs" />
+ <Compile Include="Framework\SGameConstructorHack.cs" />
<Compile Include="Framework\ContentManagers\BaseContentManager.cs" />
<Compile Include="Framework\ContentManagers\GameContentManager.cs" />
<Compile Include="Framework\ContentManagers\IContentManager.cs" />