summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/StardewModdingAPI/Framework')
-rw-r--r--src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs16
-rw-r--r--src/StardewModdingAPI/Framework/GameVersion.cs68
-rw-r--r--src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs38
-rw-r--r--src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs46
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs15
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs24
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs9
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs28
-rw-r--r--src/StardewModdingAPI/Framework/Models/Manifest.cs6
-rw-r--r--src/StardewModdingAPI/Framework/Models/ModCompatibility.cs50
-rw-r--r--src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs57
-rw-r--r--src/StardewModdingAPI/Framework/Monitor.cs61
-rw-r--r--src/StardewModdingAPI/Framework/SContentManager.cs228
-rw-r--r--src/StardewModdingAPI/Framework/SGame.cs34
-rw-r--r--src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs (renamed from src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs)21
-rw-r--r--src/StardewModdingAPI/Framework/Utilities/ContextHash.cs62
-rw-r--r--src/StardewModdingAPI/Framework/Utilities/Countdown.cs (renamed from src/StardewModdingAPI/Framework/Countdown.cs)2
17 files changed, 624 insertions, 141 deletions
diff --git a/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs b/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs
new file mode 100644
index 00000000..ec9279f1
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace StardewModdingAPI.Framework.Exceptions
+{
+ /// <summary>An exception thrown when an assembly can't be loaded by SMAPI, with all the relevant details in the message.</summary>
+ internal class SAssemblyLoadFailedException : Exception
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The error message.</param>
+ public SAssemblyLoadFailedException(string message)
+ : base(message) { }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/GameVersion.cs b/src/StardewModdingAPI/Framework/GameVersion.cs
new file mode 100644
index 00000000..48159f61
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/GameVersion.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary>
+ internal class GameVersion : SemanticVersion
+ {
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>A mapping of game to semantic versions.</summary>
+ private static readonly IDictionary<string, string> VersionMap = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ["1.01"] = "1.0.1",
+ ["1.02"] = "1.0.2",
+ ["1.03"] = "1.0.3",
+ ["1.04"] = "1.0.4",
+ ["1.05"] = "1.0.5",
+ ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes.
+ ["1.051b"] = "1.0.6-prelease2",
+ ["1.06"] = "1.0.6",
+ ["1.07"] = "1.0.7",
+ ["1.07a"] = "1.0.8-prerelease1",
+ ["1.11"] = "1.1.1"
+ };
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The game version string.</param>
+ public GameVersion(string version)
+ : base(GameVersion.GetSemanticVersionString(version)) { }
+
+ /// <summary>Get a string representation of the version.</summary>
+ public override string ToString()
+ {
+ return GameVersion.GetGameVersionString(base.ToString());
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Convert a game version string to a semantic version string.</summary>
+ /// <param name="gameVersion">The game version string.</param>
+ private static string GetSemanticVersionString(string gameVersion)
+ {
+ return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)
+ ? semanticVersion
+ : gameVersion;
+ }
+
+ /// <summary>Convert a game version string to a semantic version string.</summary>
+ /// <param name="gameVersion">The game version string.</param>
+ private static string GetGameVersionString(string gameVersion)
+ {
+ foreach (var mapping in GameVersion.VersionMap)
+ {
+ if (mapping.Value.Equals(gameVersion, StringComparison.InvariantCultureIgnoreCase))
+ return mapping.Key;
+ }
+ return gameVersion;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs
index 5f72176e..ffa78ff6 100644
--- a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
@@ -33,10 +33,19 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>The friendly mod name for use in errors.</summary>
private readonly string ModName;
+ /// <summary>Encapsulates monitoring and logging for a given module.</summary>
+ private readonly IMonitor Monitor;
+
/*********
** Accessors
*********/
+ /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary>
+ public string CurrentLocale => this.ContentManager.GetLocale();
+
+ /// <summary>The game's current locale as an enum value.</summary>
+ public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.ContentManager.GetCurrentLanguage();
+
/// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary>
internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>();
@@ -44,10 +53,10 @@ namespace StardewModdingAPI.Framework.ModHelpers
internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>();
/// <summary>Interceptors which provide the initial versions of matching content assets.</summary>
- internal IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders;
+ public IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders;
/// <summary>Interceptors which edit matching content assets after they're loaded.</summary>
- internal IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors;
+ public IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors;
/*********
@@ -58,13 +67,15 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="modFolderPath">The absolute path to the mod folder.</param>
/// <param name="modID">The unique ID of the relevant mod.</param>
/// <param name="modName">The friendly mod name for use in errors.</param>
- public ContentHelper(SContentManager contentManager, string modFolderPath, string modID, string modName)
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ public ContentHelper(SContentManager contentManager, string modFolderPath, string modID, string modName, IMonitor monitor)
: base(modID)
{
this.ContentManager = contentManager;
this.ModFolderPath = modFolderPath;
this.ModName = modName;
this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath);
+ this.Monitor = monitor;
}
/// <summary>Load content from the game folder or mod 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>
@@ -176,6 +187,25 @@ namespace StardewModdingAPI.Framework.ModHelpers
}
}
+ /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary>
+ /// <param name="key">The asset key to invalidate in the content folder.</param>
+ /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>
+ /// <returns>Returns whether the given asset key was cached.</returns>
+ public bool InvalidateCache(string key)
+ {
+ this.Monitor.Log($"Requested cache invalidation for '{key}'.", LogLevel.Trace);
+ string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent);
+ return this.ContentManager.InvalidateCache((otherKey, type) => otherKey.Equals(actualKey, StringComparison.InvariantCultureIgnoreCase));
+ }
+
+ /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary>
+ /// <typeparam name="T">The asset type to remove from the cache.</typeparam>
+ /// <returns>Returns whether any assets were invalidated.</returns>
+ public bool InvalidateCache<T>()
+ {
+ this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace);
+ return this.ContentManager.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type));
+ }
/*********
** Private methods
diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs
index 9411a97a..14a339da 100644
--- a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs
+++ b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using StardewModdingAPI.Framework.Reflection;
namespace StardewModdingAPI.Framework.ModHelpers
@@ -13,16 +13,21 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>The underlying reflection helper.</summary>
private readonly Reflector Reflector;
+ /// <summary>The mod name for error messages.</summary>
+ private readonly string ModName;
+
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modID">The unique ID of the relevant mod.</param>
+ /// <param name="modName">The mod name for error messages.</param>
/// <param name="reflector">The underlying reflection helper.</param>
- public ReflectionHelper(string modID, Reflector reflector)
+ public ReflectionHelper(string modID, string modName, Reflector reflector)
: base(modID)
{
+ this.ModName = modName;
this.Reflector = reflector;
}
@@ -37,6 +42,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns>
public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true)
{
+ this.AssertAccessAllowed(obj);
return this.Reflector.GetPrivateField<TValue>(obj, name, required);
}
@@ -47,6 +53,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true)
{
+ this.AssertAccessAllowed(type);
return this.Reflector.GetPrivateField<TValue>(type, name, required);
}
@@ -60,6 +67,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private property is not found.</param>
public IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true)
{
+ this.AssertAccessAllowed(obj);
return this.Reflector.GetPrivateProperty<TValue>(obj, name, required);
}
@@ -70,6 +78,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private property is not found.</param>
public IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true)
{
+ this.AssertAccessAllowed(type);
return this.Reflector.GetPrivateProperty<TValue>(type, name, required);
}
@@ -89,6 +98,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// </remarks>
public TValue GetPrivateValue<TValue>(object obj, string name, bool required = true)
{
+ this.AssertAccessAllowed(obj);
IPrivateField<TValue> field = this.GetPrivateField<TValue>(obj, name, required);
return field != null
? field.GetValue()
@@ -107,6 +117,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// </remarks>
public TValue GetPrivateValue<TValue>(Type type, string name, bool required = true)
{
+ this.AssertAccessAllowed(type);
IPrivateField<TValue> field = this.GetPrivateField<TValue>(type, name, required);
return field != null
? field.GetValue()
@@ -122,6 +133,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true)
{
+ this.AssertAccessAllowed(obj);
return this.Reflector.GetPrivateMethod(obj, name, required);
}
@@ -131,6 +143,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true)
{
+ this.AssertAccessAllowed(type);
return this.Reflector.GetPrivateMethod(type, name, required);
}
@@ -144,6 +157,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true)
{
+ this.AssertAccessAllowed(obj);
return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required);
}
@@ -154,7 +168,35 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="required">Whether to throw an exception if the private field is not found.</param>
public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true)
{
+ this.AssertAccessAllowed(type);
return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required);
}
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Assert that mods can use the reflection helper to access the given type.</summary>
+ /// <param name="type">The type being accessed.</param>
+ private void AssertAccessAllowed(Type type)
+ {
+#if !SMAPI_1_x
+ // validate type namespace
+ if (type.Namespace != null)
+ {
+ string rootSmapiNamespace = typeof(Program).Namespace;
+ if (type.Namespace == rootSmapiNamespace || type.Namespace.StartsWith(rootSmapiNamespace + "."))
+ throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning.");
+ }
+#endif
+ }
+
+ /// <summary>Assert that mods can use the reflection helper to access the given type.</summary>
+ /// <param name="obj">The object being accessed.</param>
+ private void AssertAccessAllowed(object obj)
+ {
+ if (obj != null)
+ this.AssertAccessAllowed(obj.GetType());
+ }
}
}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs
new file mode 100644
index 00000000..11be19fc
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Indicates the result of an assembly load.</summary>
+ internal enum AssemblyLoadStatus
+ {
+ /// <summary>The assembly was loaded successfully.</summary>
+ Okay = 1,
+
+ /// <summary>The assembly could not be loaded.</summary>
+ Failed = 2,
+
+ /// <summary>The assembly is already loaded.</summary>
+ AlreadyLoaded = 3
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
index 406d49e1..b14ae56f 100644
--- a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -6,6 +6,7 @@ using System.Reflection;
using Mono.Cecil;
using Mono.Cecil.Cil;
using StardewModdingAPI.AssemblyRewriters;
+using StardewModdingAPI.Framework.Exceptions;
namespace StardewModdingAPI.Framework.ModLoading
{
@@ -65,16 +66,27 @@ namespace StardewModdingAPI.Framework.ModLoading
AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver();
HashSet<string> visitedAssemblyNames = new HashSet<string>(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded
assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, resolver).ToArray();
- if (!assemblies.Any())
- throw new InvalidOperationException($"Could not load '{assemblyPath}' because it doesn't exist.");
- resolver.Add(assemblies.Select(p => p.Definition).ToArray());
}
+ // validate load
+ if (!assemblies.Any() || assemblies[0].Status == AssemblyLoadStatus.Failed)
+ {
+ throw new SAssemblyLoadFailedException(!File.Exists(assemblyPath)
+ ? $"Could not load '{assemblyPath}' because it doesn't exist."
+ : $"Could not load '{assemblyPath}'."
+ );
+ }
+ if (assemblies[0].Status == AssemblyLoadStatus.AlreadyLoaded)
+ throw new SAssemblyLoadFailedException($"Could not load '{assemblyPath}' because it was already loaded. Do you have two copies of this mod?");
+
// rewrite & load assemblies in leaf-to-root order
bool oneAssembly = assemblies.Length == 1;
Assembly lastAssembly = null;
foreach (AssemblyParseResult assembly in assemblies)
{
+ if (assembly.Status == AssemblyLoadStatus.AlreadyLoaded)
+ continue;
+
bool changed = this.RewriteAssembly(assembly.Definition, assumeCompatible, logPrefix: " ");
if (changed)
{
@@ -143,7 +155,7 @@ namespace StardewModdingAPI.Framework.ModLoading
// skip if already visited
if (visitedAssemblyNames.Contains(assembly.Name.Name))
- yield break;
+ yield return new AssemblyParseResult(file, null, AssemblyLoadStatus.AlreadyLoaded);
visitedAssemblyNames.Add(assembly.Name.Name);
// yield referenced assemblies
@@ -155,7 +167,7 @@ namespace StardewModdingAPI.Framework.ModLoading
}
// yield assembly
- yield return new AssemblyParseResult(file, assembly);
+ yield return new AssemblyParseResult(file, assembly, AssemblyLoadStatus.Okay);
}
/****
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs
index 69c99afe..b56a776c 100644
--- a/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs
@@ -15,6 +15,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The assembly definition.</summary>
public readonly AssemblyDefinition Definition;
+ /// <summary>The result of the assembly load.</summary>
+ public AssemblyLoadStatus Status;
+
/*********
** Public methods
@@ -22,10 +25,12 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Construct an instance.</summary>
/// <param name="file">The original assembly file.</param>
/// <param name="assembly">The assembly definition.</param>
- public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly)
+ /// <param name="status">The result of the assembly load.</param>
+ public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly, AssemblyLoadStatus status)
{
this.File = file;
this.Definition = assembly;
+ this.Status = status;
}
}
-} \ No newline at end of file
+}
diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs
index b75453b7..6b19db5c 100644
--- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs
@@ -71,9 +71,9 @@ namespace StardewModdingAPI.Framework.ModLoading
compatibility = (
from mod in compatibilityRecords
where
- mod.ID.Contains(key, StringComparer.InvariantCultureIgnoreCase)
- && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion))
- && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion)
+ mod.ID.Any(p => p.Matches(key, manifest))
+ && (mod.LowerVersion == null || !manifest.Version.IsOlderThan(mod.LowerVersion))
+ && !manifest.Version.IsNewerThan(mod.UpperVersion)
select mod
).FirstOrDefault();
}
@@ -109,15 +109,25 @@ namespace StardewModdingAPI.Framework.ModLoading
ModCompatibility compatibility = mod.Compatibility;
if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken)
{
- bool hasOfficialUrl = !string.IsNullOrWhiteSpace(mod.Compatibility.UpdateUrl);
- bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(mod.Compatibility.UnofficialUpdateUrl);
+#if SMAPI_1_x
+ bool hasOfficialUrl = mod.Compatibility.UpdateUrls.Length > 0;
+ bool hasUnofficialUrl = mod.Compatibility.UpdateUrls.Length > 1;
string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game or SMAPI";
- string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersionLabel ?? compatibility.UpperVersion} here:";
+ string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersionLabel ?? compatibility.UpperVersion.ToString()} here:";
if (hasOfficialUrl)
- error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}";
+ error += !hasUnofficialUrl ? $" {compatibility.UpdateUrls[0]}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrls[0]}";
if (hasUnofficialUrl)
- error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}";
+ error += $"{Environment.NewLine}- unofficial update: {compatibility.UpdateUrls[1]}";
+#else
+ string reasonPhrase = compatibility.ReasonPhrase ?? "it's no longer compatible";
+ string error = $"{reasonPhrase}. Please check for a ";
+ if (mod.Manifest.Version.Equals(compatibility.UpperVersion) && compatibility.UpperVersionLabel == null)
+ error += "newer version";
+ else
+ error += $"version newer than {compatibility.UpperVersionLabel ?? compatibility.UpperVersion.ToString()}";
+ error += " at " + string.Join(" or ", compatibility.UpdateUrls);
+#endif
mod.SetStatus(ModMetadataStatus.Failed, error);
continue;
@@ -161,7 +171,7 @@ namespace StardewModdingAPI.Framework.ModLoading
#if !SMAPI_1_x
{
var duplicatesByID = mods
- .GroupBy(mod => mod.Manifest.UniqueID?.Trim(), mod => mod, StringComparer.InvariantCultureIgnoreCase)
+ .GroupBy(mod => mod.Manifest?.UniqueID?.Trim(), mod => mod, StringComparer.InvariantCultureIgnoreCase)
.Where(p => p.Count() > 1);
foreach (var group in duplicatesByID)
{
diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs
index 1b5c2646..29c3517e 100644
--- a/src/StardewModdingAPI/Framework/Models/Manifest.cs
+++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs
@@ -21,18 +21,18 @@ namespace StardewModdingAPI.Framework.Models
public string Author { get; set; }
/// <summary>The mod version.</summary>
- [JsonConverter(typeof(ManifestFieldConverter))]
+ [JsonConverter(typeof(SFieldConverter))]
public ISemanticVersion Version { get; set; }
/// <summary>The minimum SMAPI version required by this mod, if any.</summary>
- [JsonConverter(typeof(ManifestFieldConverter))]
+ [JsonConverter(typeof(SFieldConverter))]
public ISemanticVersion MinimumApiVersion { get; set; }
/// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary>
public string EntryDll { get; set; }
/// <summary>The other mods that must be loaded before this mod.</summary>
- [JsonConverter(typeof(ManifestFieldConverter))]
+ [JsonConverter(typeof(SFieldConverter))]
public IManifestDependency[] Dependencies { get; set; }
/// <summary>The unique mod ID.</summary>
diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs
index 90cbd237..d3a9c533 100644
--- a/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs
+++ b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs
@@ -1,5 +1,5 @@
-using System.Runtime.Serialization;
-using Newtonsoft.Json;
+using Newtonsoft.Json;
+using StardewModdingAPI.Framework.Serialisation;
namespace StardewModdingAPI.Framework.Models
{
@@ -9,60 +9,32 @@ namespace StardewModdingAPI.Framework.Models
/*********
** Accessors
*********/
- /****
- ** From config
- ****/
/// <summary>The unique mod IDs.</summary>
- public string[] ID { get; set; }
+ [JsonConverter(typeof(SFieldConverter))]
+ public ModCompatibilityID[] ID { get; set; }
/// <summary>The mod name.</summary>
public string Name { get; set; }
/// <summary>The oldest incompatible mod version, or <c>null</c> for all past versions.</summary>
- public string LowerVersion { get; set; }
+ [JsonConverter(typeof(SFieldConverter))]
+ public ISemanticVersion LowerVersion { get; set; }
/// <summary>The most recent incompatible mod version.</summary>
- public string UpperVersion { get; set; }
+ [JsonConverter(typeof(SFieldConverter))]
+ public ISemanticVersion UpperVersion { get; set; }
/// <summary>A label to show to the user instead of <see cref="UpperVersion"/>, when the manifest version differs from the user-facing version.</summary>
public string UpperVersionLabel { get; set; }
- /// <summary>The URL the user can check for an official updated version.</summary>
- public string UpdateUrl { get; set; }
-
- /// <summary>The URL the user can check for an unofficial updated version.</summary>
- public string UnofficialUpdateUrl { get; set; }
+ /// <summary>The URLs the user can check for a newer version.</summary>
+ public string[] UpdateUrls { get; set; }
/// <summary>The reason phrase to show in the warning, or <c>null</c> to use the default value.</summary>
/// <example>"this version is incompatible with the latest version of the game"</example>
public string ReasonPhrase { get; set; }
/// <summary>Indicates how SMAPI should consider the mod.</summary>
- public ModCompatibilityType Compatibility { get; set; }
-
-
- /****
- ** Injected
- ****/
- /// <summary>The semantic version corresponding to <see cref="LowerVersion"/>.</summary>
- [JsonIgnore]
- public ISemanticVersion LowerSemanticVersion { get; set; }
-
- /// <summary>The semantic version corresponding to <see cref="UpperVersion"/>.</summary>
- [JsonIgnore]
- public ISemanticVersion UpperSemanticVersion { get; set; }
-
-
- /*********
- ** Private methods
- *********/
- /// <summary>The method called when the model finishes deserialising.</summary>
- /// <param name="context">The deserialisation context.</param>
- [OnDeserialized]
- private void OnDeserialized(StreamingContext context)
- {
- this.LowerSemanticVersion = this.LowerVersion != null ? new SemanticVersion(this.LowerVersion) : null;
- this.UpperSemanticVersion = this.UpperVersion != null ? new SemanticVersion(this.UpperVersion) : null;
- }
+ public ModCompatibilityType Compatibility { get; set; } = ModCompatibilityType.AssumeBroken;
}
}
diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs
new file mode 100644
index 00000000..98e70116
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs
@@ -0,0 +1,57 @@
+using System;
+using Newtonsoft.Json;
+
+namespace StardewModdingAPI.Framework.Models
+{
+ /// <summary>Uniquely identifies a mod for compatibility checks.</summary>
+ internal class ModCompatibilityID
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique mod ID.</summary>
+ public string ID { get; set; }
+
+ /// <summary>The mod name to disambiguate non-unique IDs, or <c>null</c> to ignore the mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The author name to disambiguate non-unique IDs, or <c>null</c> to ignore the author.</summary>
+ public string Author { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModCompatibilityID() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="data">The mod ID or a JSON string matching the <see cref="ModCompatibilityID"/> fields.</param>
+ public ModCompatibilityID(string data)
+ {
+ // JSON can be stuffed into the ID string as a convenience hack to keep JSON mod lists
+ // formatted readably. The tradeoff is that the format is a bit more magical, but that's
+ // probably acceptable since players aren't meant to edit it. It's also fairly clear what
+ // the JSON strings do, if not necessarily how.
+ if (data.StartsWith("{"))
+ JsonConvert.PopulateObject(data, this);
+ else
+ this.ID = data;
+ }
+
+ /// <summary>Get whether this ID matches a given mod manifest.</summary>
+ /// <param name="id">The mod's unique ID, or a substitute ID if it isn't set in the manifest.</param>
+ /// <param name="manifest">The manifest to check.</param>
+ public bool Matches(string id, IManifest manifest)
+ {
+ return
+ this.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase)
+ && (
+ this.Author == null
+ || this.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase)
+ || (manifest.ExtraFields.ContainsKey("Authour") && this.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase))
+ )
+ && (this.Name == null || this.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase));
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/Monitor.cs b/src/StardewModdingAPI/Framework/Monitor.cs
index 5ae24a73..c2c3a689 100644
--- a/src/StardewModdingAPI/Framework/Monitor.cs
+++ b/src/StardewModdingAPI/Framework/Monitor.cs
@@ -25,15 +25,7 @@ namespace StardewModdingAPI.Framework
private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max();
/// <summary>The console text color for each log level.</summary>
- private static readonly Dictionary<LogLevel, ConsoleColor> Colors = new Dictionary<LogLevel, ConsoleColor>
- {
- [LogLevel.Trace] = ConsoleColor.DarkGray,
- [LogLevel.Debug] = ConsoleColor.DarkGray,
- [LogLevel.Info] = ConsoleColor.White,
- [LogLevel.Warn] = ConsoleColor.Yellow,
- [LogLevel.Error] = ConsoleColor.Red,
- [LogLevel.Alert] = ConsoleColor.Magenta
- };
+ private static readonly IDictionary<LogLevel, ConsoleColor> Colors = Monitor.GetConsoleColorScheme();
/// <summary>Propagates notification that SMAPI should exit.</summary>
private readonly CancellationTokenSource ExitTokenSource;
@@ -172,5 +164,56 @@ namespace StardewModdingAPI.Framework
if (this.WriteToFile)
this.LogFile.WriteLine(fullMessage);
}
+
+ /// <summary>Get the color scheme to use for the current console.</summary>
+ private static IDictionary<LogLevel, ConsoleColor> GetConsoleColorScheme()
+ {
+#if !SMAPI_1_x
+ // scheme for dark console background
+ if (Monitor.IsDark(Console.BackgroundColor))
+ {
+#endif
+ return new Dictionary<LogLevel, ConsoleColor>
+ {
+ [LogLevel.Trace] = ConsoleColor.DarkGray,
+ [LogLevel.Debug] = ConsoleColor.DarkGray,
+ [LogLevel.Info] = ConsoleColor.White,
+ [LogLevel.Warn] = ConsoleColor.Yellow,
+ [LogLevel.Error] = ConsoleColor.Red,
+ [LogLevel.Alert] = ConsoleColor.Magenta
+ };
+#if !SMAPI_1_x
+ }
+
+ // scheme for light console background
+ return new Dictionary<LogLevel, ConsoleColor>
+ {
+ [LogLevel.Trace] = ConsoleColor.DarkGray,
+ [LogLevel.Debug] = ConsoleColor.DarkGray,
+ [LogLevel.Info] = ConsoleColor.Black,
+ [LogLevel.Warn] = ConsoleColor.DarkYellow,
+ [LogLevel.Error] = ConsoleColor.Red,
+ [LogLevel.Alert] = ConsoleColor.DarkMagenta
+ };
+#endif
+ }
+
+ /// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary>
+ /// <param name="color">The color to check.</param>
+ private static bool IsDark(ConsoleColor color)
+ {
+ switch (color)
+ {
+ case ConsoleColor.Black:
+ case ConsoleColor.Blue:
+ case ConsoleColor.DarkBlue:
+ case ConsoleColor.DarkRed:
+ case ConsoleColor.Red:
+ return true;
+
+ default:
+ return false;
+ }
+ }
}
}
diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs
index 669b0e7a..25775291 100644
--- a/src/StardewModdingAPI/Framework/SContentManager.cs
+++ b/src/StardewModdingAPI/Framework/SContentManager.cs
@@ -1,18 +1,17 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Reflection;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
-using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.AssemblyRewriters;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Metadata;
using StardewValley;
-using StardewValley.BellsAndWhistles;
-using StardewValley.Objects;
-using StardewValley.Projectiles;
namespace StardewModdingAPI.Framework
{
@@ -40,6 +39,15 @@ namespace StardewModdingAPI.Framework
/// <summary>The private <see cref="LocalizedContentManager"/> method which generates the locale portion of an asset name.</summary>
private readonly IPrivateMethod GetKeyLocale;
+ /// <summary>The language codes used in asset keys.</summary>
+ private readonly IDictionary<string, LanguageCode> KeyLocales;
+
+ /// <summary>Provides metadata for core game assets.</summary>
+ private readonly CoreAssets CoreAssets;
+
+ /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary>
+ private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
+
/*********
** Accessors
@@ -86,6 +94,11 @@ namespace StardewModdingAPI.Framework
}
else
this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic
+
+ // get asset data
+ this.CoreAssets = new CoreAssets(this.NormaliseAssetName);
+ this.KeyLocales = this.GetKeyLocales(reflection);
+
}
/// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary>
@@ -130,11 +143,21 @@ namespace StardewModdingAPI.Framework
// load asset
T data;
+ if (this.AssetsBeingLoaded.Contains(assetName))
+ {
+ this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
+ this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace);
+ data = base.Load<T>(assetName);
+ }
+ else
{
- IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName);
- IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName);
- asset = this.ApplyEditors<T>(info, asset);
- data = (T)asset.Data;
+ data = this.AssetsBeingLoaded.Track(assetName, () =>
+ {
+ IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName);
+ IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName);
+ asset = this.ApplyEditors<T>(info, asset);
+ return (T)asset.Data;
+ });
}
// update cache & return data
@@ -159,54 +182,90 @@ namespace StardewModdingAPI.Framework
return this.GetKeyLocale.Invoke<string>();
}
- /// <summary>Reset the asset cache and reload the game's static assets.</summary>
- /// <remarks>This implementation is derived from <see cref="Game1.LoadContent"/>.</remarks>
- public void Reset()
+ /// <summary>Get the cached asset keys.</summary>
+ public IEnumerable<string> GetAssetKeys()
{
- this.Monitor.Log("Resetting asset cache...", LogLevel.Trace);
- this.Cache.Clear();
-
- // from Game1.LoadContent
- Game1.daybg = this.Load<Texture2D>("LooseSprites\\daybg");
- Game1.nightbg = this.Load<Texture2D>("LooseSprites\\nightbg");
- Game1.menuTexture = this.Load<Texture2D>("Maps\\MenuTiles");
- Game1.lantern = this.Load<Texture2D>("LooseSprites\\Lighting\\lantern");
- Game1.windowLight = this.Load<Texture2D>("LooseSprites\\Lighting\\windowLight");
- Game1.sconceLight = this.Load<Texture2D>("LooseSprites\\Lighting\\sconceLight");
- Game1.cauldronLight = this.Load<Texture2D>("LooseSprites\\Lighting\\greenLight");
- Game1.indoorWindowLight = this.Load<Texture2D>("LooseSprites\\Lighting\\indoorWindowLight");
- Game1.shadowTexture = this.Load<Texture2D>("LooseSprites\\shadow");
- Game1.mouseCursors = this.Load<Texture2D>("LooseSprites\\Cursors");
- Game1.controllerMaps = this.Load<Texture2D>("LooseSprites\\ControllerMaps");
- Game1.animations = this.Load<Texture2D>("TileSheets\\animations");
- Game1.achievements = this.Load<Dictionary<int, string>>("Data\\Achievements");
- Game1.NPCGiftTastes = this.Load<Dictionary<string, string>>("Data\\NPCGiftTastes");
- Game1.dialogueFont = this.Load<SpriteFont>("Fonts\\SpriteFont1");
- Game1.smallFont = this.Load<SpriteFont>("Fonts\\SmallFont");
- Game1.tinyFont = this.Load<SpriteFont>("Fonts\\tinyFont");
- Game1.tinyFontBorder = this.Load<SpriteFont>("Fonts\\tinyFontBorder");
- Game1.objectSpriteSheet = this.Load<Texture2D>("Maps\\springobjects");
- Game1.cropSpriteSheet = this.Load<Texture2D>("TileSheets\\crops");
- Game1.emoteSpriteSheet = this.Load<Texture2D>("TileSheets\\emotes");
- Game1.debrisSpriteSheet = this.Load<Texture2D>("TileSheets\\debris");
- Game1.bigCraftableSpriteSheet = this.Load<Texture2D>("TileSheets\\Craftables");
- Game1.rainTexture = this.Load<Texture2D>("TileSheets\\rain");
- Game1.buffsIcons = this.Load<Texture2D>("TileSheets\\BuffsIcons");
- Game1.objectInformation = this.Load<Dictionary<int, string>>("Data\\ObjectInformation");
- Game1.bigCraftablesInformation = this.Load<Dictionary<int, string>>("Data\\BigCraftablesInformation");
- FarmerRenderer.hairStylesTexture = this.Load<Texture2D>("Characters\\Farmer\\hairstyles");
- FarmerRenderer.shirtsTexture = this.Load<Texture2D>("Characters\\Farmer\\shirts");
- FarmerRenderer.hatsTexture = this.Load<Texture2D>("Characters\\Farmer\\hats");
- FarmerRenderer.accessoriesTexture = this.Load<Texture2D>("Characters\\Farmer\\accessories");
- Furniture.furnitureTexture = this.Load<Texture2D>("TileSheets\\furniture");
- SpriteText.spriteTexture = this.Load<Texture2D>("LooseSprites\\font_bold");
- SpriteText.coloredTexture = this.Load<Texture2D>("LooseSprites\\font_colored");
- Tool.weaponsTexture = this.Load<Texture2D>("TileSheets\\weapons");
- Projectile.projectileSheet = this.Load<Texture2D>("TileSheets\\Projectiles");
-
- // from Farmer constructor
- if (Game1.player != null)
- Game1.player.FarmerRenderer = new FarmerRenderer(this.Load<Texture2D>("Characters\\Farmer\\farmer_" + (Game1.player.isMale ? "" : "girl_") + "base"));
+ IEnumerable<string> GetAllAssetKeys()
+ {
+ foreach (string cacheKey in this.Cache.Keys)
+ {
+ this.ParseCacheKey(cacheKey, out string assetKey, out string _);
+ yield return assetKey;
+ }
+ }
+
+ return GetAllAssetKeys().Distinct();
+ }
+
+ /// <summary>Purge assets from the cache that match one of the interceptors.</summary>
+ /// <param name="editors">The asset editors for which to purge matching assets.</param>
+ /// <param name="loaders">The asset loaders for which to purge matching assets.</param>
+ /// <returns>Returns whether any cache entries were invalidated.</returns>
+ public bool InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
+ {
+ if (!editors.Any() && !loaders.Any())
+ return false;
+
+ // get CanEdit/Load methods
+ MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
+ MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
+
+ // invalidate matching keys
+ return this.InvalidateCache((assetName, assetType) =>
+ {
+ // get asset metadata
+ IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName);
+
+ // check loaders
+ MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(assetType);
+ if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { info })))
+ return true;
+
+ // check editors
+ MethodInfo canEditGeneric = canEdit.MakeGenericMethod(assetType);
+ return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { info }));
+ });
+ }
+
+ /// <summary>Purge matched assets from the cache.</summary>
+ /// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <returns>Returns whether any cache entries were invalidated.</returns>
+ public bool InvalidateCache(Func<string, Type, bool> predicate)
+ {
+ // find matching asset keys
+ HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ HashSet<string> purgeAssetKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
+ foreach (string cacheKey in this.Cache.Keys)
+ {
+ this.ParseCacheKey(cacheKey, out string assetKey, out _);
+ Type type = this.Cache[cacheKey].GetType();
+ if (predicate(assetKey, type))
+ {
+ purgeAssetKeys.Add(assetKey);
+ purgeCacheKeys.Add(cacheKey);
+ }
+ }
+
+ // purge from cache
+ foreach (string key in purgeCacheKeys)
+ this.Cache.Remove(key);
+
+ // reload core game assets
+ int reloaded = 0;
+ foreach (string key in purgeAssetKeys)
+ {
+ if (this.CoreAssets.ReloadForKey(this, key))
+ reloaded++;
+ }
+
+ // report result
+ if (purgeCacheKeys.Any())
+ {
+ this.Monitor.Log($"Invalidated {purgeCacheKeys.Count} cache entries for {purgeAssetKeys.Count} asset keys: {string.Join(", ", purgeCacheKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
+ return true;
+ }
+ this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
+ return false;
}
@@ -221,6 +280,60 @@ namespace StardewModdingAPI.Framework
|| this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset
}
+ /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>
+ /// <param name="reflection">Simplifies access to private game code.</param>
+ private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection)
+ {
+ // get the private code field directly to avoid changed-code logic
+ IPrivateField<LanguageCode> codeField = reflection.GetPrivateField<LanguageCode>(typeof(LocalizedContentManager), "_currentLangCode");
+
+ // remember previous settings
+ LanguageCode previousCode = codeField.GetValue();
+ string previousOverride = this.LanguageCodeOverride;
+
+ // create locale => code map
+ IDictionary<string, LanguageCode> map = new Dictionary<string, LanguageCode>(StringComparer.InvariantCultureIgnoreCase);
+ this.LanguageCodeOverride = null;
+ foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode)))
+ {
+ codeField.SetValue(code);
+ map[this.GetKeyLocale.Invoke<string>()] = code;
+ }
+
+ // restore previous settings
+ codeField.SetValue(previousCode);
+ this.LanguageCodeOverride = previousOverride;
+
+ return map;
+ }
+
+ /// <summary>Parse a cache key into its component parts.</summary>
+ /// <param name="cacheKey">The input cache key.</param>
+ /// <param name="assetKey">The original asset key.</param>
+ /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param>
+ private void ParseCacheKey(string cacheKey, out string assetKey, out string localeCode)
+ {
+ // handle localised key
+ if (!string.IsNullOrWhiteSpace(cacheKey))
+ {
+ int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture);
+ if (lastSepIndex >= 0)
+ {
+ string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
+ if (this.KeyLocales.ContainsKey(suffix))
+ {
+ assetKey = cacheKey.Substring(0, lastSepIndex);
+ localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
+ return;
+ }
+ }
+ }
+
+ // handle simple key
+ assetKey = cacheKey;
+ localeCode = null;
+ }
+
/// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary>
/// <param name="info">The basic asset metadata.</param>
/// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns>
@@ -365,7 +478,8 @@ namespace StardewModdingAPI.Framework
// can't know which assets are meant to be disposed. Here we remove current assets from
// the cache, but don't dispose them to avoid crashing any code that still references
// them. The garbage collector will eventually clean up any unused assets.
- this.Reset();
+ this.Monitor.Log("Content manager disposed, resetting cache.", LogLevel.Trace);
+ this.InvalidateCache((key, type) => true);
}
}
}
diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs
index d6f1a05b..997e0c8c 100644
--- a/src/StardewModdingAPI/Framework/SGame.cs
+++ b/src/StardewModdingAPI/Framework/SGame.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
@@ -11,6 +11,7 @@ using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Utilities;
using StardewValley;
using StardewValley.BellsAndWhistles;
@@ -19,7 +20,9 @@ using StardewValley.Menus;
using StardewValley.Tools;
using xTile.Dimensions;
using xTile.Layers;
+#if SMAPI_1_x
using SFarmer = StardewValley.Farmer;
+#endif
namespace StardewModdingAPI.Framework
{
@@ -54,10 +57,6 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the game is saving and SMAPI has already raised <see cref="SaveEvents.BeforeSave"/>.</summary>
private bool IsBetweenSaveEvents;
- /// <summary>Whether the game's zoom level is at 100% (i.e. nothing should be scaled).</summary>
- public bool ZoomLevelIsOne => Game1.options.zoomLevel.Equals(1.0f);
-
-
/****
** Game state
****/
@@ -76,7 +75,10 @@ namespace StardewModdingAPI.Framework
/// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary>
private Point PreviousMousePosition;
- /// <summary>The previous save ID at last check.</summary>
+ /// <summary>The window size value at last check.</summary>
+ private Point PreviousWindowSize;
+
+ /// <summary>The save ID at last check.</summary>
private ulong PreviousSaveID;
/// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary>
@@ -318,6 +320,11 @@ namespace StardewModdingAPI.Framework
*********/
if (Context.IsSaveLoaded && !SaveGame.IsProcessing /*still loading save*/ && this.AfterLoadTimer >= 0)
{
+#if !SMAPI_1_x
+ if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet)
+#endif
+ this.AfterLoadTimer--;
+
if (this.AfterLoadTimer == 0)
{
this.Monitor.Log($"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace);
@@ -329,7 +336,6 @@ namespace StardewModdingAPI.Framework
#endif
TimeEvents.InvokeAfterDayStarted(this.Monitor);
}
- this.AfterLoadTimer--;
}
/*********
@@ -350,6 +356,20 @@ namespace StardewModdingAPI.Framework
}
/*********
+ ** 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 (Game1.viewport.Width != this.PreviousWindowSize.X || Game1.viewport.Height != this.PreviousWindowSize.Y)
+ {
+ Point size = new Point(Game1.viewport.Width, Game1.viewport.Height);
+ GraphicsEvents.InvokeResize(this.Monitor);
+ this.PreviousWindowSize = size;
+ }
+
+ /*********
** Input events (if window has focus)
*********/
if (Game1.game1.IsActive)
diff --git a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs
index 6947311b..11ffdccb 100644
--- a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs
+++ b/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs
@@ -8,7 +8,7 @@ using StardewModdingAPI.Framework.Models;
namespace StardewModdingAPI.Framework.Serialisation
{
/// <summary>Overrides how SMAPI reads and writes <see cref="ISemanticVersion"/> and <see cref="IManifestDependency"/> fields.</summary>
- internal class ManifestFieldConverter : JsonConverter
+ internal class SFieldConverter : JsonConverter
{
/*********
** Accessors
@@ -24,7 +24,10 @@ namespace StardewModdingAPI.Framework.Serialisation
/// <param name="objectType">The object type.</param>
public override bool CanConvert(Type objectType)
{
- return objectType == typeof(ISemanticVersion) || objectType == typeof(IManifestDependency[]);
+ return
+ objectType == typeof(ISemanticVersion)
+ || objectType == typeof(IManifestDependency[])
+ || objectType == typeof(ModCompatibilityID[]);
}
/// <summary>Reads the JSON representation of the object.</summary>
@@ -83,6 +86,20 @@ namespace StardewModdingAPI.Framework.Serialisation
return result.ToArray();
}
+ // mod compatibility ID
+ if (objectType == typeof(ModCompatibilityID[]))
+ {
+ List<ModCompatibilityID> result = new List<ModCompatibilityID>();
+ foreach (JToken child in JArray.Load(reader).Children())
+ {
+ result.Add(child is JValue value
+ ? new ModCompatibilityID(value.Value<string>())
+ : child.ToObject<ModCompatibilityID>()
+ );
+ }
+ return result.ToArray();
+ }
+
// unknown
throw new NotSupportedException($"Unknown type '{objectType?.FullName}'.");
}
diff --git a/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs b/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs
new file mode 100644
index 00000000..0d8487bb
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework.Utilities
+{
+ /// <summary>A <see cref="HashSet{T}"/> wrapper meant for tracking recursive contexts.</summary>
+ /// <typeparam name="T">The key type.</typeparam>
+ internal class ContextHash<T> : HashSet<T>
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ContextHash() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="comparer">The <see cref="IEqualityComparer{T}"/> implementation to use when comparing values in the set, or <c>null</c> to use the default comparer for the set type.</param>
+ public ContextHash(IEqualityComparer<T> comparer)
+ : base(comparer) { }
+
+ /// <summary>Add a key while an action is in progress, and remove it when it completes.</summary>
+ /// <param name="key">The key to add.</param>
+ /// <param name="action">The action to perform.</param>
+ /// <exception cref="InvalidOperationException">The specified key is already added.</exception>
+ public void Track(T key, Action action)
+ {
+ if(this.Contains(key))
+ throw new InvalidOperationException($"Can't track context for key {key} because it's already added.");
+
+ this.Add(key);
+ try
+ {
+ action();
+ }
+ finally
+ {
+ this.Remove(key);
+ }
+ }
+
+ /// <summary>Add a key while an action is in progress, and remove it when it completes.</summary>
+ /// <typeparam name="TResult">The value type returned by the method.</typeparam>
+ /// <param name="key">The key to add.</param>
+ /// <param name="action">The action to perform.</param>
+ public TResult Track<TResult>(T key, Func<TResult> action)
+ {
+ if (this.Contains(key))
+ throw new InvalidOperationException($"Can't track context for key {key} because it's already added.");
+
+ this.Add(key);
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ this.Remove(key);
+ }
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/Countdown.cs b/src/StardewModdingAPI/Framework/Utilities/Countdown.cs
index 25ca2546..921a35ce 100644
--- a/src/StardewModdingAPI/Framework/Countdown.cs
+++ b/src/StardewModdingAPI/Framework/Utilities/Countdown.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI.Framework
+namespace StardewModdingAPI.Framework.Utilities
{
/// <summary>Counts down from a baseline value.</summary>
internal class Countdown