summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/GlobalAssemblyInfo.cs4
-rw-r--r--src/StardewModdingAPI.Tests/Utilities/SDateTests.cs4
-rw-r--r--src/StardewModdingAPI/Constants.cs2
-rw-r--r--src/StardewModdingAPI/Framework/ContentManagerShim.cs50
-rw-r--r--src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs135
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs4
-rw-r--r--src/StardewModdingAPI/Framework/SContentManager.cs67
-rw-r--r--src/StardewModdingAPI/Framework/SGame.cs13
-rw-r--r--src/StardewModdingAPI/Program.cs3
-rw-r--r--src/StardewModdingAPI/StardewModdingAPI.csproj1
-rw-r--r--src/StardewModdingAPI/Utilities/SButton.cs28
-rw-r--r--src/StardewModdingAPI/Utilities/SDate.cs58
-rw-r--r--src/TrainerMod/manifest.json4
13 files changed, 280 insertions, 93 deletions
diff --git a/src/GlobalAssemblyInfo.cs b/src/GlobalAssemblyInfo.cs
index 4b15d3aa..882e3bda 100644
--- a/src/GlobalAssemblyInfo.cs
+++ b/src/GlobalAssemblyInfo.cs
@@ -2,5 +2,5 @@ using System.Reflection;
using System.Runtime.InteropServices;
[assembly: ComVisible(false)]
-[assembly: AssemblyVersion("1.15.3.0")]
-[assembly: AssemblyFileVersion("1.15.3.0")]
+[assembly: AssemblyVersion("1.15.4.0")]
+[assembly: AssemblyFileVersion("1.15.4.0")]
diff --git a/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs b/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs
index 714756e0..25acbaf3 100644
--- a/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs
+++ b/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
@@ -104,6 +104,8 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition
[TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition
[TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition
+ [TestCase("06 fall Y2", 50, ExpectedResult = "28 winter Y3")] // test for zero-index errors
+ [TestCase("06 fall Y2", 51, ExpectedResult = "01 spring Y3")] // test for zero-index errors
public string AddDays(string dateStr, int addDays)
{
return this.GetDate(dateStr).AddDays(addDays).ToString();
diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs
index 8ed4f416..fea9377a 100644
--- a/src/StardewModdingAPI/Constants.cs
+++ b/src/StardewModdingAPI/Constants.cs
@@ -36,7 +36,7 @@ namespace StardewModdingAPI
/// <summary>SMAPI's current semantic version.</summary>
public static ISemanticVersion ApiVersion { get; } =
#if SMAPI_1_x
- new SemanticVersion(1, 15, 3);
+ new SemanticVersion(1, 15, 4);
#else
new SemanticVersion(2, 0, 0, $"alpha-{DateTime.UtcNow:yyyyMMddHHmm}");
#endif
diff --git a/src/StardewModdingAPI/Framework/ContentManagerShim.cs b/src/StardewModdingAPI/Framework/ContentManagerShim.cs
new file mode 100644
index 00000000..d46f23a3
--- /dev/null
+++ b/src/StardewModdingAPI/Framework/ContentManagerShim.cs
@@ -0,0 +1,50 @@
+using StardewValley;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>A minimal content manager which defers to SMAPI's main content manager.</summary>
+ internal class ContentManagerShim : LocalizedContentManager
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>SMAPI's underlying content manager.</summary>
+ private readonly SContentManager ContentManager;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The content manager's name for logs (if any).</summary>
+ public string Name { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="contentManager">SMAPI's underlying content manager.</param>
+ /// <param name="name">The content manager's name for logs (if any).</param>
+ public ContentManagerShim(SContentManager contentManager, string name)
+ : base(contentManager.ServiceProvider, contentManager.RootDirectory, contentManager.CurrentCulture, contentManager.LanguageCodeOverride)
+ {
+ this.ContentManager = contentManager;
+ this.Name = name;
+ }
+
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ public override T Load<T>(string assetName)
+ {
+ return this.ContentManager.LoadFor<T>(assetName, this);
+ }
+
+ /// <summary>Dispose held resources.</summary>
+ /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
+ protected override void Dispose(bool disposing)
+ {
+ this.ContentManager.DisposeFor(this);
+ }
+ }
+}
diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs
index ffa78ff6..4440ae40 100644
--- a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs
+++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs
@@ -214,49 +214,129 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="map">The map whose tilesheets to fix.</param>
/// <param name="mapKey">The map asset key within the mod folder.</param>
/// <exception cref="ContentLoadException">The map tilesheets could not be loaded.</exception>
+ /// <remarks>
+ /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils down to this:
+ /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded as-is relative to the <c>Content</c> folder.
+ /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix.
+ ///
+ /// That logic doesn't work well in our case, mainly because we have no location metadata at this point.
+ /// Instead we use a more heuristic approach: check relative to the map file first, then relative to
+ /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, we try
+ /// for a seasonal variation and then an exact match.
+ ///
+ /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference.
+ /// </remarks>
private void FixLocalMapTilesheets(Map map, string mapKey)
{
+ // check map info
if (!map.TileSheets.Any())
return;
-
+ mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder
+
+ // fix tilesheets
foreach (TileSheet tilesheet in map.TileSheets)
{
- // check for tilesheet relative to map
+ string imageSource = tilesheet.ImageSource;
+
+ // get seasonal name (if applicable)
+ string seasonalImageSource = null;
+ if (Game1.currentSeason != null)
{
- string localKey = Path.Combine(relativeMapFolder, tilesheet.ImageSource);
- FileInfo localFile = this.GetModFile(localKey);
- if (localFile.Exists)
+ string filename = Path.GetFileName(imageSource);
+ bool hasSeasonalPrefix =
+ filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase)
+ || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase)
+ || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase)
+ || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase);
+ if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_"))
{
- try
- {
- this.Load<Texture2D>(localKey);
- }
- catch (Exception ex)
- {
- throw new ContentLoadException($"The local '{tilesheet.ImageSource}' tilesheet couldn't be loaded.", ex);
- }
- tilesheet.ImageSource = this.GetActualAssetKey(localKey);
+ string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase));
+ seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}";
+ }
+ }
+
+ // load best match
+ try
+ {
+ string key =
+ this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource)
+ ?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource);
+ if (key != null)
+ {
+ tilesheet.ImageSource = key;
continue;
}
}
+ catch (Exception ex)
+ {
+ throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex);
+ }
+
+ // none found
+ throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.");
+ }
+ }
+
+ /// <summary>Load a tilesheet image source if the file exists.</summary>
+ /// <param name="relativeMapFolder">The folder path containing the map, relative to the mod folder.</param>
+ /// <param name="imageSource">The tilesheet image source to load.</param>
+ /// <returns>Returns the loaded asset key (if it was loaded successfully).</returns>
+ /// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks>
+ private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource)
+ {
+ if (imageSource == null)
+ return null;
- // fallback to game content
+ // check relative to map file
+ {
+ string localKey = Path.Combine(relativeMapFolder, imageSource);
+ FileInfo localFile = this.GetModFile(localKey);
+ if (localFile.Exists)
{
- string contentKey = tilesheet.ImageSource;
- if (contentKey.EndsWith(".png"))
- contentKey = contentKey.Substring(0, contentKey.Length - 4);
try
{
- this.ContentManager.Load<Texture2D>(contentKey);
+ this.Load<Texture2D>(localKey);
}
catch (Exception ex)
{
- throw new ContentLoadException($"The '{tilesheet.ImageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex);
+ throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex);
+ }
+
+ return this.GetActualAssetKey(localKey);
+ }
+ }
+
+ // check relative to content folder
+ {
+ foreach (string candidateKey in new[] { imageSource, $@"Maps\{imageSource}" })
+ {
+ string contentKey = candidateKey.EndsWith(".png")
+ ? candidateKey.Substring(0, imageSource.Length - 4)
+ : candidateKey;
+
+ try
+ {
+ this.Load<Texture2D>(contentKey, ContentSource.GameContent);
+ return contentKey;
+ }
+ catch
+ {
+ // ignore file-not-found errors
+ // TODO: while it's useful to suppress a asset-not-found error here to avoid
+ // confusion, this is a pretty naive approach. Even if the file doesn't exist,
+ // the file may have been loaded through an IAssetLoader which failed. So even
+ // if the content file doesn't exist, that doesn't mean the error here is a
+ // content-not-found error. Unfortunately XNA doesn't provide a good way to
+ // detect the error type.
+ if (this.GetContentFolderFile(contentKey).Exists)
+ throw;
}
- tilesheet.ImageSource = contentKey;
}
}
+
+ // not found
+ return null;
}
/// <summary>Assert that the given key has a valid format.</summary>
@@ -290,6 +370,19 @@ namespace StardewModdingAPI.Framework.ModHelpers
return file;
}
+ /// <summary>Get a file from the game's content folder.</summary>
+ /// <param name="key">The asset key.</param>
+ private FileInfo GetContentFolderFile(string key)
+ {
+ // get file path
+ string path = Path.Combine(this.ContentManager.FullRootDirectory, key);
+ if (!path.EndsWith(".xnb"))
+ path += ".xnb";
+
+ // get file
+ return new FileInfo(path);
+ }
+
/// <summary>Get the asset path which loads a mod folder through a content manager.</summary>
/// <param name="localPath">The file path relative to the mod's folder.</param>
/// <param name="absolutePath">The absolute file path.</param>
diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
index e6ec21a6..9c642bef 100644
--- a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -91,7 +91,7 @@ namespace StardewModdingAPI.Framework.ModLoading
if (changed)
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name}.dll (rewritten in memory)...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace);
using (MemoryStream outStream = new MemoryStream())
{
assembly.Definition.Write(outStream);
@@ -102,7 +102,7 @@ namespace StardewModdingAPI.Framework.ModLoading
else
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name}.dll...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName);
}
}
diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs
index 25775291..9553e79f 100644
--- a/src/StardewModdingAPI/Framework/SContentManager.cs
+++ b/src/StardewModdingAPI/Framework/SContentManager.cs
@@ -48,6 +48,9 @@ namespace StardewModdingAPI.Framework
/// <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>();
+ /// <summary>A lookup of the content managers which loaded each asset.</summary>
+ private readonly IDictionary<string, HashSet<ContentManager>> AssetLoaders = new Dictionary<string, HashSet<ContentManager>>();
+
/*********
** Accessors
@@ -98,7 +101,6 @@ namespace StardewModdingAPI.Framework
// 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>
@@ -135,11 +137,23 @@ namespace StardewModdingAPI.Framework
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
public override T Load<T>(string assetName)
{
+ return this.LoadFor<T>(assetName, this);
+ }
+
+ /// <summary>Load an asset that has been processed by the content pipeline.</summary>
+ /// <typeparam name="T">The type of asset to load.</typeparam>
+ /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
+ /// <param name="instance">The content manager instance for which to load the asset.</param>
+ public T LoadFor<T>(string assetName, ContentManager instance)
+ {
assetName = this.NormaliseAssetName(assetName);
// skip if already loaded
if (this.IsNormalisedKeyLoaded(assetName))
+ {
+ this.TrackAssetLoader(assetName, instance);
return base.Load<T>(assetName);
+ }
// load asset
T data;
@@ -162,6 +176,7 @@ namespace StardewModdingAPI.Framework
// update cache & return data
this.Cache[assetName] = data;
+ this.TrackAssetLoader(assetName, instance);
return data;
}
@@ -172,8 +187,8 @@ namespace StardewModdingAPI.Framework
public void Inject<T>(string assetName, T value)
{
assetName = this.NormaliseAssetName(assetName);
-
this.Cache[assetName] = value;
+ this.TrackAssetLoader(assetName, this);
}
/// <summary>Get the current content locale.</summary>
@@ -229,8 +244,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param>
+ /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns whether any cache entries were invalidated.</returns>
- public bool InvalidateCache(Func<string, Type, bool> predicate)
+ public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{
// find matching asset keys
HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
@@ -246,9 +262,14 @@ namespace StardewModdingAPI.Framework
}
}
- // purge from cache
+ // purge assets
foreach (string key in purgeCacheKeys)
+ {
+ if (dispose && this.Cache[key] is IDisposable disposable)
+ disposable.Dispose();
this.Cache.Remove(key);
+ this.AssetLoaders.Remove(key);
+ }
// reload core game assets
int reloaded = 0;
@@ -268,6 +289,17 @@ namespace StardewModdingAPI.Framework
return false;
}
+ /// <summary>Dispose assets for the given content manager shim.</summary>
+ /// <param name="shim">The content manager whose assets to dispose.</param>
+ internal void DisposeFor(ContentManagerShim shim)
+ {
+ this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace);
+
+ foreach (var entry in this.AssetLoaders)
+ entry.Value.Remove(shim);
+ this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true);
+ }
+
/*********
** Private methods
@@ -280,6 +312,16 @@ namespace StardewModdingAPI.Framework
|| this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset
}
+ /// <summary>Track that a content manager loaded an asset.</summary>
+ /// <param name="key">The asset key that was loaded.</param>
+ /// <param name="manager">The content manager that loaded the asset.</param>
+ private void TrackAssetLoader(string key, ContentManager manager)
+ {
+ if (!this.AssetLoaders.TryGetValue(key, out HashSet<ContentManager> hash))
+ hash = this.AssetLoaders[key] = new HashSet<ContentManager>();
+ hash.Add(manager);
+ }
+
/// <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)
@@ -463,23 +505,12 @@ namespace StardewModdingAPI.Framework
}
}
- /// <summary>Dispose all game resources.</summary>
+ /// <summary>Dispose held resources.</summary>
/// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param>
protected override void Dispose(bool disposing)
{
- if (!disposing)
- return;
-
- // Clear cache & reload all assets. While that may seem perverse during disposal, it's
- // necessary due to limitations in the way SMAPI currently intercepts content assets.
- //
- // The game uses multiple content managers while SMAPI needs one and only one. The game
- // only disposes some of its content managers when returning to title, which means SMAPI
- // 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.Monitor.Log("Content manager disposed, resetting cache.", LogLevel.Trace);
- this.InvalidateCache((key, type) => true);
+ this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace);
+ base.Dispose(disposing);
}
}
}
diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs
index 997e0c8c..76c106d7 100644
--- a/src/StardewModdingAPI/Framework/SGame.cs
+++ b/src/StardewModdingAPI/Framework/SGame.cs
@@ -38,9 +38,6 @@ namespace StardewModdingAPI.Framework
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
- /// <summary>SMAPI's content manager.</summary>
- private readonly SContentManager SContentManager;
-
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary>
private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second
@@ -177,6 +174,9 @@ namespace StardewModdingAPI.Framework
/*********
** Accessors
*********/
+ /// <summary>SMAPI's content manager.</summary>
+ public SContentManager SContentManager { get; }
+
/// <summary>Whether SMAPI should log more information about the game context.</summary>
public bool VerboseLogging { get; set; }
@@ -201,8 +201,9 @@ namespace StardewModdingAPI.Framework
// override content manager
this.Monitor?.Log("Overriding content manager...", LogLevel.Trace);
this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor);
- this.Content = this.SContentManager;
- Game1.content = this.SContentManager;
+ this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content");
+ Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content");
+ reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager
}
/****
@@ -225,7 +226,7 @@ namespace StardewModdingAPI.Framework
throw new InvalidOperationException("SMAPI uses a single content manager internally. You can't get a new content manager with a different service provider.");
if (rootDirectory != this.Content.RootDirectory)
throw new InvalidOperationException($"SMAPI uses a single content manager internally. You can't get a new content manager with a different root directory (current is {this.Content.RootDirectory}, requested {rootDirectory}).");
- return this.SContentManager;
+ return new ContentManagerShim(this.SContentManager, "(generated instance)");
}
/// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary>
diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs
index 108e9273..ad873598 100644
--- a/src/StardewModdingAPI/Program.cs
+++ b/src/StardewModdingAPI/Program.cs
@@ -53,7 +53,7 @@ namespace StardewModdingAPI
private SGame GameInstance;
/// <summary>The underlying content manager.</summary>
- private SContentManager ContentManager => (SContentManager)this.GameInstance.Content;
+ private SContentManager ContentManager => this.GameInstance.SContentManager;
/// <summary>The SMAPI configuration settings.</summary>
/// <remarks>This is initialised after the game starts.</remarks>
@@ -128,6 +128,7 @@ namespace StardewModdingAPI
// init logging
this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info);
this.Monitor.Log($"Mods go here: {Constants.ModPath}");
+ this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace);
#if SMAPI_1_x
this.Monitor.Log("Preparing SMAPI...");
#endif
diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj
index 8c7279a1..8daf21b7 100644
--- a/src/StardewModdingAPI/StardewModdingAPI.csproj
+++ b/src/StardewModdingAPI/StardewModdingAPI.csproj
@@ -91,6 +91,7 @@
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Command.cs" />
+ <Compile Include="Framework\ContentManagerShim.cs" />
<Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" />
<Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" />
<Compile Include="Framework\Utilities\ContextHash.cs" />
diff --git a/src/StardewModdingAPI/Utilities/SButton.cs b/src/StardewModdingAPI/Utilities/SButton.cs
index c4833b0b..33058a64 100644
--- a/src/StardewModdingAPI/Utilities/SButton.cs
+++ b/src/StardewModdingAPI/Utilities/SButton.cs
@@ -1,5 +1,6 @@
-using System;
+using System;
using Microsoft.Xna.Framework.Input;
+using StardewValley;
namespace StardewModdingAPI.Utilities
{
@@ -655,5 +656,30 @@ namespace StardewModdingAPI.Utilities
button = 0;
return false;
}
+
+ /// <summary>Get the <see cref="InputButton"/> equivalent for the given button.</summary>
+ /// <param name="input">The button to convert.</param>
+ /// <param name="button">The Stardew Valley input button equivalent.</param>
+ /// <returns>Returns whether the value was converted successfully.</returns>
+ public static bool TryGetStardewInput(this SButton input, out InputButton button)
+ {
+ // keyboard
+ if (input.TryGetKeyboard(out Keys key))
+ {
+ button = new InputButton(key);
+ return true;
+ }
+
+ // mouse
+ if (input == SButton.MouseLeft || input == SButton.MouseRight)
+ {
+ button = new InputButton(mouseLeft: input == SButton.MouseLeft);
+ return true;
+ }
+
+ // not valid
+ button = default(InputButton);
+ return false;
+ }
}
}
diff --git a/src/StardewModdingAPI/Utilities/SDate.cs b/src/StardewModdingAPI/Utilities/SDate.cs
index d7631598..5073259d 100644
--- a/src/StardewModdingAPI/Utilities/SDate.cs
+++ b/src/StardewModdingAPI/Utilities/SDate.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Linq;
using StardewValley;
@@ -86,34 +86,27 @@ namespace StardewModdingAPI.Utilities
/// <exception cref="ArithmeticException">The offset would result in an invalid date (like year 0).</exception>
public SDate AddDays(int offset)
{
- // simple case
- int day = this.Day + offset;
- string season = this.Season;
- int year = this.Year;
+ // get new hash code
+ int hashCode = this.GetHashCode() + offset;
+ if (hashCode < 1)
+ throw new ArithmeticException($"Adding {offset} days to {this} would result in a date before 01 spring Y1.");
- // handle season transition
- if (day > this.DaysInSeason || day < 1)
- {
- // get season index
- int curSeasonIndex = this.GetSeasonIndex();
-
- // get season offset
- int seasonOffset = day / this.DaysInSeason;
- if (day < 1)
- seasonOffset -= 1;
-
- // get new date
- day = this.GetWrappedIndex(day, this.DaysInSeason);
- season = this.Seasons[this.GetWrappedIndex(curSeasonIndex + seasonOffset, this.Seasons.Length)];
- year += seasonOffset / this.Seasons.Length;
- }
+ // get day
+ int day = hashCode % 28;
+ if (day == 0)
+ day = 28;
- // validate
- if (year < 1)
- throw new ArithmeticException($"Adding {offset} days to {this} would result in invalid date {day:00} {season} {year}.");
+ // get season index
+ int seasonIndex = hashCode / 28;
+ if (seasonIndex > 0 && hashCode % 28 == 0)
+ seasonIndex -= 1;
+ seasonIndex %= 4;
- // return new date
- return new SDate(day, season, year);
+ // get year
+ int year = hashCode / (this.Seasons.Length * this.DaysInSeason) + 1;
+
+ // create date
+ return new SDate(day, this.Seasons[seasonIndex], year);
}
/// <summary>Get a string representation of the date. This is mainly intended for debugging or console messages.</summary>
@@ -142,7 +135,7 @@ namespace StardewModdingAPI.Utilities
/// <summary>Get a hash code which uniquely identifies a date.</summary>
public override int GetHashCode()
{
- // return the number of days since 01 spring Y1
+ // return the number of days since 01 spring Y1 (inclusively)
int yearIndex = this.Year - 1;
return
yearIndex * this.DaysInSeason * this.SeasonsInYear
@@ -239,16 +232,5 @@ namespace StardewModdingAPI.Utilities
throw new InvalidOperationException($"The current season '{this.Season}' wasn't recognised.");
return index;
}
-
- /// <summary>Get the real index in an array which should be treated as a two-way loop.</summary>
- /// <param name="index">The index in the looped array.</param>
- /// <param name="length">The number of elements in the array.</param>
- private int GetWrappedIndex(int index, int length)
- {
- int wrapped = index % length;
- if (wrapped < 0)
- wrapped += length;
- return wrapped;
- }
}
}
diff --git a/src/TrainerMod/manifest.json b/src/TrainerMod/manifest.json
index 5c634f53..20b40f8a 100644
--- a/src/TrainerMod/manifest.json
+++ b/src/TrainerMod/manifest.json
@@ -1,10 +1,10 @@
-{
+{
"Name": "Trainer Mod",
"Author": "SMAPI",
"Version": {
"MajorVersion": 1,
"MinorVersion": 15,
- "PatchVersion": 1,
+ "PatchVersion": 4,
"Build": null
},
"Description": "Adds SMAPI console commands that let you manipulate the game.",