diff options
40 files changed, 347 insertions, 170 deletions
diff --git a/build/common.targets b/build/common.targets index 230758bd..230bef41 100644 --- a/build/common.targets +++ b/build/common.targets @@ -7,7 +7,7 @@ repo. It imports the other MSBuild files as needed. <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <!--set general build properties --> - <Version>3.15.0</Version> + <Version>3.15.1</Version> <Product>SMAPI</Product> <LangVersion>latest</LangVersion> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> diff --git a/docs/release-notes.md b/docs/release-notes.md index 414396f4..6c3edb77 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,13 +9,28 @@ ## Upcoming release * For players: - * Fixed lag which occurred for some players since Stardew Valley 1.5.5. + * Fixed Linux/macOS installer's color theme question partly unreadable if the terminal background is dark. + +* For the web UI: + * Added log parser warning about performance of PyTK 1.23.0 or earlier. + +## 3.15.1 +Released 06 July 2022 for Stardew Valley 1.5.6 or later. + +* For players: + * Added current version to update alerts (thanks to ishan!). + * Fixed lag for some players since Stardew Valley 1.5.5. * Fixed `smapi-internal/config.user.json` overrides not applied after SMAPI 3.14.0. + * Fixed PyTK not rescaling images correctly in some cases. + _When PyTK 1.23.0 or earlier is installed, this will disable the main performance improvements in SMAPI 3.15.0._ + * Updated compatibility list. * For mod authors: * The [FluentHttpClient package](https://github.com/Pathoschild/FluentHttpClient#readme) is now loaded by SMAPI. + * Fixed `TRACE` logs not tracking reloaded map tilesheets as a propagated asset. * For the web UI: + * Added log parser suggested fix for missing/outdated Error Handler, and improved visual styles. * Updated the JSON validator/schema for Content Patcher 1.27.0. * Fixed the mod count in the log parser metadata. diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 5a6aa747..d00a5df4 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -206,7 +206,7 @@ namespace StardewModdingApi.Installer Console.WriteLine(); // handle choice - string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }); + string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }, printLine: Console.WriteLine); switch (choice) { case "1": @@ -453,6 +453,7 @@ namespace StardewModdingApi.Installer } // find target folder + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- avoid error if the Mods folder has invalid mods, since they're not validated yet ModFolder? targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(sourceMod.Manifest.UniqueID, StringComparison.OrdinalIgnoreCase) == true); DirectoryInfo defaultTargetFolder = new(Path.Combine(paths.ModsPath, sourceMod.Directory.Name)); DirectoryInfo targetFolder = targetMod?.Directory ?? defaultTargetFolder; @@ -628,22 +629,22 @@ namespace StardewModdingApi.Installer } /// <summary>Interactively ask the user to choose a value.</summary> - /// <param name="print">A callback which prints a message to the console.</param> + /// <param name="printLine">A callback which prints a message to the console.</param> /// <param name="message">The message to print.</param> /// <param name="options">The allowed options (not case sensitive).</param> /// <param name="indent">The indentation to prefix to output.</param> - private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string>? print = null) + private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string>? printLine = null) { - print ??= this.PrintInfo; + printLine ??= this.PrintInfo; while (true) { - print(indent + message); + printLine(indent + message); Console.Write(indent); string? input = Console.ReadLine()?.Trim().ToLowerInvariant(); if (input == null || !options.Contains(input)) { - print($"{indent}That's not a valid option."); + printLine($"{indent}That's not a valid option."); continue; } return input; diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index c7026ee1..88412d92 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -227,8 +227,7 @@ namespace StardewModdingAPI.ModBuildConfig string fromPath = entry.Value.FullName; string toPath = Path.Combine(modFolderPath, entry.Key); - // ReSharper disable once AssignNullToNotNullAttribute -- not applicable in this context - Directory.CreateDirectory(Path.GetDirectoryName(toPath)); + Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); File.Copy(fromPath, toPath, overwrite: true); } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 3722e155..88ddfe6b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /// <summary>Get all spawnable items.</summary> /// <param name="itemTypes">The item types to fetch (or null for any type).</param> /// <param name="includeVariants">Whether to include flavored variants like "Sunflower Honey".</param> - [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")] + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = $"{nameof(ItemRepository.TryCreate)} invokes the lambda immediately.")] public IEnumerable<SearchableItem> GetAll(ItemType[]? itemTypes = null, bool includeVariants = true) { // diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 300de9d2..3c2dec19 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.15.0", + "Version": "3.15.1", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.15.0" + "MinimumApiVersion": "3.15.1" } diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index 15a1e0f3..28b4b149 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.15.0", + "Version": "3.15.1", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.15.0" + "MinimumApiVersion": "3.15.1" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 1a11742c..1944575b 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.15.0", + "Version": "3.15.1", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.15.0" + "MinimumApiVersion": "3.15.1" } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index 7f06d170..3bdd145a 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -283,8 +283,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } /// <summary>The response model for the MediaWiki parse API.</summary> - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] private class ResponseModel { /********* @@ -306,9 +306,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } /// <summary>The inner response model for the MediaWiki parse API.</summary> - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] - [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialization.")] + [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local", Justification = "Used via JSON deserialization.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialization.")] private class ResponseParseModel { /********* diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs index 6978567e..f464f4bb 100644 --- a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -21,7 +21,8 @@ namespace StardewModdingAPI.Toolkit.Framework /// <summary>Get the OS name from the system uname command.</summary> /// <param name="buffer">The buffer to fill with the resulting string.</param> [DllImport("libc")] - static extern int uname(IntPtr buffer); + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "This is the actual external command name.")] + private static extern int uname(IntPtr buffer); /********* @@ -51,7 +52,6 @@ namespace StardewModdingAPI.Toolkit.Framework /// <summary>Get the human-readable OS name and version.</summary> /// <param name="platform">The current platform.</param> - [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] public static string GetFriendlyPlatformName(string platform) { #if SMAPI_FOR_WINDOWS @@ -65,7 +65,10 @@ namespace StardewModdingAPI.Toolkit.Framework return result ?? "Windows"; } - catch { } + catch + { + // fallback to default behavior + } #endif string name = Environment.OSVersion.ToString(); diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs index 0df75a31..55b9bdd8 100644 --- a/src/SMAPI.Toolkit/ModToolkit.cs +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -65,7 +65,7 @@ namespace StardewModdingAPI.Toolkit /// <param name="metadataPath">The file path for the SMAPI metadata file.</param> public ModDatabase GetModDatabase(string metadataPath) { - MetadataModel metadata = JsonConvert.DeserializeObject<MetadataModel>(File.ReadAllText(metadataPath)); + MetadataModel metadata = JsonConvert.DeserializeObject<MetadataModel>(File.ReadAllText(metadataPath)) ?? new MetadataModel(); ModDataRecord[] records = metadata.ModData.Select(pair => new ModDataRecord(pair.Key, pair.Value)).ToArray(); return new ModDatabase(records, this.GetUpdateUrl); } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index c32c3185..913d54e0 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -48,7 +48,12 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters return this.ReadObject(JObject.Load(reader)); case JsonToken.String: - return this.ReadString(JToken.Load(reader).Value<string>(), path); + { + string? value = JToken.Load(reader).Value<string>(); + return value is not null + ? this.ReadString(value, path) + : null; + } default: throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path})."); diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs index 1c59f5e7..cdf2ed77 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs @@ -42,7 +42,12 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters return this.ReadObject(JObject.Load(reader), path); case JsonToken.String: - return this.ReadString(JToken.Load(reader).Value<string>(), path); + { + string? value = JToken.Load(reader).Value<string>(); + return value is not null + ? this.ReadString(value, path) + : null; + } default: throw new SParseException($"Can't parse {typeof(T).Name} from {reader.TokenType} node (path: {reader.Path})."); diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index c60b2c90..f5a5f930 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The nullability is validated in this method.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The nullability is validated in this method.")] public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 7fc8f958..0efa62c5 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -200,6 +200,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing log.GameVersion = match.Groups["gameVersion"].Value; log.OperatingSystem = match.Groups["os"].Value; smapiMod.OverrideVersion(log.ApiVersion); + + log.ApiVersionParsed = smapiMod.GetParsedVersion(); } // mod path line diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs index 4b80a830..557f08ff 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs @@ -1,4 +1,6 @@ +using System; using System.Diagnostics.CodeAnalysis; +using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework.LogParsing.Models { @@ -6,6 +8,13 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models public class LogModInfo { /********* + ** Private fields + *********/ + /// <summary>The parsed mod version, if valid.</summary> + private Lazy<ISemanticVersion?> ParsedVersionImpl; + + + /********* ** Accessors *********/ /// <summary>The mod name.</summary> @@ -68,7 +77,6 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models { this.Name = name; this.Author = author; - this.Version = version; this.Description = description; this.UpdateVersion = updateVersion; this.UpdateLink = updateLink; @@ -82,6 +90,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models this.IsContentPack = !string.IsNullOrWhiteSpace(this.ContentPackFor); this.IsCodeMod = !this.IsContentPack; } + + this.OverrideVersion(version); } /// <summary>Add an update alert for this mod.</summary> @@ -95,9 +105,29 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models /// <summary>Override the version number, for cases like SMAPI itself where the version is only known later during parsing.</summary> /// <param name="version">The new mod version.</param> + [MemberNotNull(nameof(LogModInfo.Version), nameof(LogModInfo.ParsedVersionImpl))] public void OverrideVersion(string version) { this.Version = version; + this.ParsedVersionImpl = new Lazy<ISemanticVersion?>(this.ParseVersion); + } + + /// <summary>Get the semantic version for this mod, if it's valid.</summary> + public ISemanticVersion? GetParsedVersion() + { + return this.ParsedVersionImpl.Value; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the semantic version for this mod, if it's valid.</summary> + public ISemanticVersion? ParseVersion() + { + return !string.IsNullOrWhiteSpace(this.Version) && SemanticVersion.TryParse(this.Version, out ISemanticVersion? version) + ? version + : null; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index 6951e434..3f649199 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -28,6 +28,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models /// <summary>The SMAPI version.</summary> public string? ApiVersion { get; set; } + /// <summary>The parsed SMAPI version, if it's valid.</summary> + public ISemanticVersion? ApiVersionParsed { get; set; } + /// <summary>The game version.</summary> public string? GameVersion { get; set; } diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 33239a2b..57e26ace 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -8,24 +8,30 @@ @{ ViewData["Title"] = "SMAPI log parser"; + // get log info ParsedLog? log = Model!.ParsedLog; - IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod(); + ISet<int> screenIds = new HashSet<int>(log?.Messages.Select(p => p.ScreenId) ?? Array.Empty<int>()); + + // detect suggested fixes + LogModInfo[] outdatedMods = log?.Mods.Where(mod => mod.HasUpdate).ToArray() ?? Array.Empty<LogModInfo>(); + LogModInfo? errorHandler = log?.Mods.FirstOrDefault(p => p.IsCodeMod && p.Name == "Error Handler"); + bool hasOlderErrorHandler = errorHandler?.GetParsedVersion() is not null && log?.ApiVersionParsed is not null && log.ApiVersionParsed.IsNewerThan(errorHandler.GetParsedVersion()); + bool isPyTkCompatibilityMode = log?.ApiVersionParsed?.IsOlderThan("3.15.0") is false && log.Mods.Any(p => p.IsCodeMod && p.Name == "PyTK" && p.GetParsedVersion()?.IsOlderThan("1.23.1") is true); + + // get filters IDictionary<string, bool> defaultFilters = Enum .GetValues<LogLevel>() .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); - IDictionary<int, string> logLevels = Enum .GetValues<LogLevel>() .ToDictionary(level => (int)level, level => level.ToString().ToLower()); - IDictionary<int, string> logSections = Enum .GetValues<LogSection>() .ToDictionary(section => (int)section, section => section.ToString()); + // get form string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true)!; - - ISet<int> screenIds = new HashSet<int>(log?.Messages.Select(p => p.ScreenId) ?? Array.Empty<int>()); } @section Head { @@ -34,7 +40,7 @@ <meta name="robots" content="noindex" /> } <link rel="stylesheet" href="~/Content/css/file-upload.css" /> - <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20220409" /> + <link rel="stylesheet" href="~/Content/css/log-parser.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" /> <script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script> @@ -69,7 +75,7 @@ </text> } </script> - + <script> $(function() { smapi.logParser( @@ -158,7 +164,7 @@ else if (log?.IsValid == true) <div id="os-instructions"> <div> <ul data-tabs> - @foreach (Platform platform in new[] {Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows}) + @foreach (Platform platform in new[] { Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows }) { @if (platform == Platform.Windows) { @@ -237,55 +243,70 @@ else if (log?.IsValid == true) @if (log?.IsValid == true) { <div id="output"> - @if (log.Mods.Any(mod => mod.HasUpdate)) + @if (outdatedMods.Any() || errorHandler is null || hasOlderErrorHandler || isPyTkCompatibilityMode) { <h2>Suggested fixes</h2> <ul id="fix-list"> - <li> - Consider updating these mods to fix problems: + @if (errorHandler is null) + { + <li class="important">You don't have the <strong>Error Handler</strong> mod installed. This automatically prevents many game or mod errors. You can <a href="https://stardewvalleywiki.com/Modding:Player_Guide#Install_SMAPI">reinstall SMAPI</a> to re-add it.</li> + } + @if (hasOlderErrorHandler) + { + <li>Your <strong>Error Handler</strong> mod is older than SMAPI. You may be missing some game/mod error fixes. You can <a href="https://stardewvalleywiki.com/Modding:Player_Guide#Install_SMAPI">reinstall SMAPI</a> to update it.</li> + } + @if (isPyTkCompatibilityMode) + { + <li>PyTK 1.23.0 or earlier isn't compatible with newer SMAPI performance optimizations. This may increase loading times or in-game lag.</li> + } + @if (outdatedMods.Any()) + { + <li> + Consider updating these mods to fix problems: - <table id="updates" class="table"> - @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) - { - <tr class="mod-entry"> - <td> - <strong class=@(!mod.HasUpdate ? "hidden" : "")>@mod.Name</strong> - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) - { - <div class="content-packs"> - @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) - { - <text>+ @contentPack.Name</text><br /> - } - </div> - } - </td> - <td> - @if (mod.HasUpdate) - { - <a href="@mod.UpdateLink" target="_blank"> - @(mod.Version == null ? mod.UpdateVersion : $"{mod.Version} → {mod.UpdateVersion}") - </a> - } - else - { - <text> </text> - } + <table id="updates" class="table"> + @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) + { + <tr class="mod-entry"> + <td> + <strong class=@(!mod.HasUpdate ? "hidden" : "")>@mod.Name</strong> + @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) + { + <div class="content-packs"> + @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) + { + <text>+ @contentPack.Name</text><br /> + } + </div> + } + </td> + <td> + @if (mod.HasUpdate) + { + <a href="@mod.UpdateLink" target="_blank"> + @(mod.Version == null ? mod.UpdateVersion : $"{mod.Version} → {mod.UpdateVersion}") + </a> + } + else + { + <text> </text> + } - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) - { - <div> - @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) - { - <a href="@contentPack.UpdateLink" target="_blank">@contentPack.Version → @contentPack.UpdateVersion</a><br /> - } - </div> - } - </td> - </tr> - } - </table> - </li> + @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) + { + <div> + @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) + { + <a href="@contentPack.UpdateLink" target="_blank">@contentPack.Version → @contentPack.UpdateVersion</a><br /> + } + </div> + } + </td> + </tr> + } + </table> + </li> + } </ul> } @@ -434,7 +455,7 @@ else if (log?.IsValid == true) <div> This website uses JavaScript to display a filterable table. To view this log, please enable JavaScript or <a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, format = LogViewFormat.RawView })">view the raw log</a>. </div> - <br/> + <br /> </noscript> <log-table> diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 1d457e35..f136a96f 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -54,6 +54,36 @@ table caption { } /********* +** Suggested fixes +*********/ +#fix-list { + padding-left: 1em; + margin-bottom: 2em; +} + +#fix-list li { + padding: 0.5em; + background: #FFC; + border: 1px solid #880; + border-radius: 5px; + list-style-type: none; +} + +#fix-list li:not(:last-child) { + margin-bottom: 0.5em; +} + +#fix-list li.important { + background: #FCC; + border-color: #800; +} + +#fix-list li::before { + content: "⚠ "; +} + + +/********* ** Log metadata & filters *********/ .table, #filters { @@ -84,10 +114,6 @@ table caption { min-height: 1.3em; } -#fix-list { - margin-bottom: 2em; -} - #updates { min-width: 10em; } diff --git a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index 4e6f4669..d654b181 100644 --- a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -166,8 +166,8 @@ *********/ "CFAutomate": { "ID": "Platonymous.CFAutomate", - "~2.12.9 | Status": "AssumeBroken", - "~2.12.9 | StatusReasonDetails": "causes runtime errors in newer versions of Automate" + "~2.12.11 | Status": "AssumeBroken", + "~2.12.11 | StatusReasonDetails": "causes runtime errors in newer versions of Automate" }, "Dynamic Game Assets": { "ID": "spacechase0.DynamicGameAssets", diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 33468717..c59af612 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -52,7 +52,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.15.0"; + internal static string RawApiVersion = "3.15.1"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -244,8 +244,8 @@ namespace StardewModdingAPI internal static void ConfigureAssemblyResolver(AssemblyDefinitionResolver resolver) { // add search paths - resolver.AddSearchDirectory(Constants.GamePath); - resolver.AddSearchDirectory(Constants.InternalFilesPath); + resolver.TryAddSearchDirectory(Constants.GamePath); + resolver.TryAddSearchDirectory(Constants.InternalFilesPath); // add SMAPI explicitly // Normally this would be handled automatically by the search paths, but for some reason there's a specific diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index d7be0c37..54f8e2a2 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -111,7 +111,6 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <inheritdoc /> - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Copied as-is from game code")] public sealed override string LoadBaseString(string path) { try @@ -119,7 +118,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // copied as-is from LocalizedContentManager.LoadBaseString // This is only changed to call this.Load instead of base.Load, to support mod assets this.ParseStringPath(path, out string assetName, out string key); - Dictionary<string, string> strings = this.Load<Dictionary<string, string>>(assetName, LanguageCode.en); + Dictionary<string, string>? strings = this.Load<Dictionary<string, string>?>(assetName, LanguageCode.en); return strings != null && strings.ContainsKey(key) ? this.GetString(strings, key) : path; diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 8c5d0f84..f3cf05d9 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -47,9 +46,6 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary> private static readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; - /// <summary>A lookup of image file paths to whether they have PyTK scaling information.</summary> - private static readonly Dictionary<string, bool> IsPyTkScaled = new(StringComparer.OrdinalIgnoreCase); - /********* ** Accessors @@ -211,24 +207,13 @@ namespace StardewModdingAPI.Framework.ContentManagers { if (ModContentManager.EnablePyTkLegacyMode) { - if (!ModContentManager.IsPyTkScaled.TryGetValue(file.FullName, out bool isScaled)) - { - string? dirPath = file.DirectoryName; - string fileName = $"{Path.GetFileNameWithoutExtension(file.Name)}.pytk.json"; - - string path = dirPath is not null - ? Path.Combine(dirPath, fileName) - : fileName; - - ModContentManager.IsPyTkScaled[file.FullName] = isScaled = File.Exists(path); - } - - asRawData = !isScaled; - if (!asRawData) - this.Monitor.LogOnce("Enabled compatibility mode for PyTK scaled textures. This won't cause any issues, but may impact performance.", LogLevel.Warn); + // PyTK intercepts Texture2D file loads to rescale them (e.g. for HD portraits), + // but doesn't support IRawTextureData loads yet. We can't just check if the + // current file has a '.pytk.json' rescale file though, since PyTK may still + // rescale it if the original asset or another edit gets rescaled. + asRawData = false; + this.Monitor.LogOnce("Enabled compatibility mode for PyTK 1.23.0 or earlier. This won't cause any issues, but may impact performance.", LogLevel.Warn); } - else - asRawData = true; } // load diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index d811ed5c..c0b7c0ba 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -400,7 +400,7 @@ namespace StardewModdingAPI.Framework.Logging /// <param name="mods">The loaded mods.</param> /// <param name="skippedMods">The mods which could not be loaded.</param> /// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifests aren't guaranteed non-null at this point in the loading process.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "Manifests aren't guaranteed non-null at this point in the loading process.")] private void LogModWarnings(IEnumerable<IModMetadata> mods, IModMetadata[] skippedMods, bool logParanoidWarnings) { // get mods with warnings diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 9ac3b6f7..caa66bad 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -88,6 +88,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="modDirectory">The full path to the mod's folder.</param> /// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param> /// <param name="events">Manages access to events raised by SMAPI.</param> + /// <param name="contentHelper">An API for loading content assets.</param> /// <param name="gameContentHelper">An API for loading content assets from the game's <c>Content</c> folder or via <see cref="IModEvents.Content"/>.</param> /// <param name="modContentHelper">An API for loading content assets from your mod's files.</param> /// <param name="contentPackHelper">An API for managing content packs.</param> diff --git a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs index b3378ad1..5a850255 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs @@ -4,18 +4,31 @@ using Mono.Cecil; namespace StardewModdingAPI.Framework.ModLoading { /// <summary>A minimal assembly definition resolver which resolves references to known assemblies.</summary> - internal class AssemblyDefinitionResolver : DefaultAssemblyResolver + internal class AssemblyDefinitionResolver : IAssemblyResolver { /********* ** Fields *********/ + /// <summary>The underlying assembly resolver.</summary> + private readonly DefaultAssemblyResolverWrapper Resolver = new(); + /// <summary>The known assemblies.</summary> private readonly IDictionary<string, AssemblyDefinition> Lookup = new Dictionary<string, AssemblyDefinition>(); + /// <summary>The directory paths to search for assemblies.</summary> + private readonly HashSet<string> SearchPaths = new(); + /********* ** Public methods *********/ + /// <summary>Construct an instance.</summary> + public AssemblyDefinitionResolver() + { + foreach (string path in this.Resolver.GetSearchDirectories()) + this.SearchPaths.Add(path); + } + /// <summary>Add known assemblies to the resolver.</summary> /// <param name="assemblies">The known assemblies.</param> public void Add(params AssemblyDefinition[] assemblies) @@ -29,7 +42,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="names">The assembly names for which it should be returned.</param> public void AddWithExplicitNames(AssemblyDefinition assembly, params string[] names) { - this.RegisterAssembly(assembly); + this.Resolver.AddAssembly(assembly); foreach (string name in names) this.Lookup[name] = assembly; } @@ -37,18 +50,52 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Resolve an assembly reference.</summary> /// <param name="name">The assembly name.</param> /// <exception cref="AssemblyResolutionException">The assembly can't be resolved.</exception> - public override AssemblyDefinition Resolve(AssemblyNameReference name) + public AssemblyDefinition Resolve(AssemblyNameReference name) { - return this.ResolveName(name.Name) ?? base.Resolve(name); + return this.ResolveName(name.Name) ?? this.Resolver.Resolve(name); } /// <summary>Resolve an assembly reference.</summary> /// <param name="name">The assembly name.</param> /// <param name="parameters">The assembly reader parameters.</param> /// <exception cref="AssemblyResolutionException">The assembly can't be resolved.</exception> - public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) + public AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) { - return this.ResolveName(name.Name) ?? base.Resolve(name, parameters); + return this.ResolveName(name.Name) ?? this.Resolver.Resolve(name, parameters); + } + + /// <summary>Add a directory path to search for assemblies, if it's non-null and not already added.</summary> + /// <param name="path">The path to search.</param> + /// <returns>Returns whether the path was successfully added.</returns> + public bool TryAddSearchDirectory(string? path) + { + if (path is not null && this.SearchPaths.Add(path)) + { + this.Resolver.AddSearchDirectory(path); + return true; + } + + return false; + } + + /// <summary>Remove a directory path to search for assemblies, if it's non-null.</summary> + /// <param name="path">The path to remove.</param> + /// <returns>Returns whether the path was in the list and removed.</returns> + public bool RemoveSearchDirectory(string? path) + { + if (path is not null && this.SearchPaths.Remove(path)) + { + this.Resolver.RemoveSearchDirectory(path); + return true; + } + + return false; + } + + /// <inheritdoc /> + public void Dispose() + { + this.Resolver.Dispose(); } @@ -63,5 +110,16 @@ namespace StardewModdingAPI.Framework.ModLoading ? match : null; } + + /// <summary>An internal wrapper around <see cref="DefaultAssemblyResolver"/> to allow access to its protected methods.</summary> + private class DefaultAssemblyResolverWrapper : DefaultAssemblyResolver + { + /// <summary>Add an assembly to the resolver.</summary> + /// <param name="assembly">The assembly to add.</param> + public void AddAssembly(AssemblyDefinition assembly) + { + this.RegisterAssembly(assembly); + } + } } } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index eb940c41..01037870 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -264,8 +264,15 @@ namespace StardewModdingAPI.Framework.ModLoading if (!file.Exists) yield break; // not a local assembly + // add the assembly's directory temporarily if needed + // this is needed by F# mods which bundle FSharp.Core.dll, for example + string? temporarySearchDir = null; + if (this.AssemblyDefinitionResolver.TryAddSearchDirectory(file.DirectoryName)) + temporarySearchDir = file.DirectoryName; + // read assembly AssemblyDefinition assembly; + try { byte[] assemblyBytes = File.ReadAllBytes(file.FullName); Stream readStream = this.TrackForDisposal(new MemoryStream(assemblyBytes)); @@ -286,6 +293,12 @@ namespace StardewModdingAPI.Framework.ModLoading assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true })); } } + finally + { + // clean up temporary search directory + if (temporarySearchDir is not null) + this.AssemblyDefinitionResolver.RemoveSearchDirectory(temporarySearchDir); + } // skip if already visited if (visitedAssemblyNames.Contains(assembly.Name.Name)) diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index aa4d2d8c..ac7a6bbd 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -83,7 +83,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <inheritdoc /> [MemberNotNullWhen(true, nameof(ModMetadata.ContentPack))] - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The manifest may be null for broken mods while loading.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The manifest may be null for broken mods while loading.")] public bool IsContentPack => this.Manifest?.ContentPackFor != null; /// <summary>The fake content packs created by this mod, if any.</summary> diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 3e7144f9..abc46d47 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -60,8 +60,8 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param> /// <param name="getFileLookup">Get a file lookup for the given directory.</param> /// <param name="validateFilesExist">Whether to validate that files referenced in the manifest (like <see cref="IManifest.EntryDll"/>) exist on disk. This can be disabled to only validate the manifest itself.</param> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifest values may be null before they're validated.")] - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Manifest values may be null before they're validated.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "Manifest values may be null before they're validated.")] + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "Manifest values may be null before they're validated.")] public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string?> getUpdateUrl, Func<string, IFileLookup> getFileLookup, bool validateFilesExist = true) { mods = mods.ToArray(); diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs index 9c8ba2b0..be45272e 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades return new Harmony(id); } - [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "If the user passes a null original method, we let it fail in the underlying Harmony instance instead of handling it here.")] + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract", Justification = "If the user passes a null original method, we let it fail in the underlying Harmony instance instead of handling it here.")] public DynamicMethod Patch(MethodBase original, HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null) { // In Harmony 1.x you could target a virtual method that's not implemented by the diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs index 67569424..3eb31df3 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs @@ -10,7 +10,6 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades /// <summary>Provides <see cref="SpriteBatch"/> method signatures that can be injected into mod code for compatibility with mods written for XNA Framework before Stardew Valley 1.5.5.</summary> /// <remarks>This is public to support SMAPI rewriting and should not be referenced directly by mods.</remarks> [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used via assembly rewriting")] - [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Linux/macOS.")] [SuppressMessage("ReSharper", "CS1591", Justification = "Documentation not needed for facade classes.")] public class SpriteBatchFacade : SpriteBatch { diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 62b15405..9444c046 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -100,7 +100,7 @@ namespace StardewModdingAPI.Framework.Models /// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param> /// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param> /// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param> - public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? usePintail, bool? useRawImageLoading, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks) + public SConfig(bool developerMode, bool? checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? useRawImageLoading, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks) { this.DeveloperMode = developerMode; this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)]; diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs index 1e150508..cc936489 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Framework.Networking *********/ /// <summary>Construct an instance.</summary> /// <param name="mod">The mod metadata.</param> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The ID shouldn't be null, but we should handle it to avoid an error just in case.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The ID shouldn't be null, but we should handle it to avoid an error just in case.")] public MultiplayerPeerMod(RemoteContextModModel mod) { this.Name = mod.Name; diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs index 94b13378..dac41629 100644 --- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.Rendering { /// <summary>A map display device which reimplements the default logic.</summary> /// <remarks>This is an exact copy of <see cref="XnaDisplayDevice"/>, except that private fields are protected and all methods are virtual.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Field naming deliberately matches " + nameof(XnaDisplayDevice) + " to minimize differences.")] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = $"Field naming deliberately matches {nameof(XnaDisplayDevice)} to minimize differences.")] internal class SXnaDisplayDevice : IDisplayDevice { /********* diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index fdfe70fc..46d65f6a 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -191,7 +191,7 @@ namespace StardewModdingAPI.Framework string logPath = this.GetLogPath(); // init basics - this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)) ?? throw new InvalidOperationException("The 'smapi-internal/config.json' file is missing or invalid. You can reinstall SMAPI to fix this."); if (File.Exists(Constants.ApiUserConfigPath)) JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); if (developerMode.HasValue) @@ -325,7 +325,7 @@ namespace StardewModdingAPI.Framework } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "May be disposed before SMAPI is fully initialized.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "May be disposed before SMAPI is fully initialized.")] public void Dispose() { // skip if already disposed @@ -1285,7 +1285,7 @@ namespace StardewModdingAPI.Framework private LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { // Game1._temporaryContent initializing from SGame constructor - // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- this is the method that initializes it + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- this is the method that initializes it if (this.ContentCore == null) { this.ContentCore = new ContentCoordinator( @@ -1565,7 +1565,7 @@ namespace StardewModdingAPI.Framework this.Monitor.Newline(); this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); foreach ((IModMetadata mod, ISemanticVersion newVersion, string newUrl) in updates) - this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); + this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl} (you have {mod.Manifest.Version})", LogLevel.Alert); } else this.Monitor.Log(" All mods up to date."); @@ -1808,7 +1808,7 @@ namespace StardewModdingAPI.Framework string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]..."); - // ReSharper disable once ConstantConditionalAccessQualifier -- mod may be invalid at this point + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- mod may be invalid at this point else if (mod.Manifest?.EntryDll != null) this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})..."); // don't use Path.Combine here, since EntryDLL might not be valid else diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 38043e1c..feb0988a 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -252,6 +252,7 @@ namespace StardewModdingAPI.Framework [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "PossibleNullReferenceException", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantCast", Justification = "copied from game code as-is")] @@ -261,6 +262,8 @@ namespace StardewModdingAPI.Framework [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")] + + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Deliberate to minimize chance of errors when copying event calls into new versions of this code.")] private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen) { var events = this.Events; diff --git a/src/SMAPI/Framework/Serialization/KeybindConverter.cs b/src/SMAPI/Framework/Serialization/KeybindConverter.cs index 539f1291..f7b8e67e 100644 --- a/src/SMAPI/Framework/Serialization/KeybindConverter.cs +++ b/src/SMAPI/Framework/Serialization/KeybindConverter.cs @@ -49,7 +49,10 @@ namespace StardewModdingAPI.Framework.Serialization case JsonToken.String: { - string str = JToken.Load(reader).Value<string>(); + string? str = JToken.Load(reader).Value<string>(); + + if (str is null) + return new Keybind(Array.Empty<SButton>()); if (objectType == typeof(Keybind)) { diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index b783b2b9..1ef9a8f2 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -164,6 +164,7 @@ namespace StardewModdingAPI.Metadata var content = this.MainContentManager; string key = assetName.BaseName; changedWarpRoutes = false; + bool changed = false; /**** ** Special case: current map tilesheet @@ -175,7 +176,10 @@ namespace StardewModdingAPI.Metadata foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { if (this.IsSameBaseName(assetName, tilesheet.ImageSource)) + { Game1.mapDisplayDevice.LoadTileSheet(tilesheet); + changed = true; + } } } @@ -184,8 +188,6 @@ namespace StardewModdingAPI.Metadata ****/ if (type == typeof(Map)) { - bool anyChanged = false; - if (!ignoreWorld) { foreach (LocationInfo info in this.GetLocationsWithInfo()) @@ -206,12 +208,12 @@ namespace StardewModdingAPI.Metadata var newWarps = GetWarpSet(location); changedWarpRoutes = changedWarpRoutes || oldWarps.Count != newWarps.Count || oldWarps.Any(p => !newWarps.Contains(p)); - anyChanged = true; + changed = true; } } } - return anyChanged; + return changed; } /**** @@ -223,7 +225,7 @@ namespace StardewModdingAPI.Metadata ** Animals ****/ case "animals/horse": - return !ignoreWorld && this.UpdatePetOrHorseSprites<Horse>(assetName); + return changed | (!ignoreWorld && this.UpdatePetOrHorseSprites<Horse>(assetName)); /**** ** Buildings @@ -239,7 +241,7 @@ namespace StardewModdingAPI.Metadata Farm farm = Game1.getFarm(); farm?.ApplyHousePaint(); - return removedFromCache || farm != null; + return changed | (removedFromCache || farm != null); } /**** @@ -253,7 +255,7 @@ namespace StardewModdingAPI.Metadata case "characters/farmer/farmer_base_bald": case "characters/farmer/farmer_girl_base": case "characters/farmer/farmer_girl_base_bald": - return !ignoreWorld && this.UpdatePlayerSprites(assetName); + return changed | (!ignoreWorld && this.UpdatePlayerSprites(assetName)); case "characters/farmer/hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = this.LoadTexture(key); @@ -305,10 +307,10 @@ namespace StardewModdingAPI.Metadata return true; case "data/farmanimals": // FarmAnimal constructor - return !ignoreWorld && this.UpdateFarmAnimalData(); + return changed | (!ignoreWorld && this.UpdateFarmAnimalData()); case "data/hairdata": // Farmer.GetHairStyleMetadataFile - return this.UpdateHairData(); + return changed | this.UpdateHairData(); case "data/movies": // MovieTheater.GetMovieData case "data/moviesreactions": // MovieTheater.GetMovieReactions @@ -316,7 +318,7 @@ namespace StardewModdingAPI.Metadata return true; case "data/npcdispositions": // NPC constructor - return !ignoreWorld && this.UpdateNpcDispositions(content, assetName); + return changed | (!ignoreWorld && this.UpdateNpcDispositions(content, assetName)); case "data/npcgifttastes": // Game1.LoadContent Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key); @@ -428,7 +430,7 @@ namespace StardewModdingAPI.Metadata return true; case "loosesprites/suspensionbridge": // SuspensionBridge constructor - return !ignoreWorld && this.UpdateSuspensionBridges(content, assetName); + return changed | (!ignoreWorld && this.UpdateSuspensionBridges(content, assetName)); /**** ** Content\Maps @@ -456,16 +458,16 @@ namespace StardewModdingAPI.Metadata return true; } } - return false; + return changed; case "minigames/titlebuttons": // TitleMenu - return this.UpdateTitleButtons(content, assetName); + return changed | this.UpdateTitleButtons(content, assetName); /**** ** Content\Strings ****/ case "strings/stringsfromcsfiles": - return this.UpdateStringsFromCsFiles(content); + return changed | this.UpdateStringsFromCsFiles(content); /**** ** Content\TileSheets @@ -490,7 +492,7 @@ namespace StardewModdingAPI.Metadata return true; case "tilesheets/critters": // Critter constructor - return !ignoreWorld && this.UpdateCritterTextures(assetName); + return changed | (!ignoreWorld && this.UpdateCritterTextures(assetName)); case "tilesheets/crops": // Game1.LoadContent Game1.cropSpriteSheet = content.Load<Texture2D>(key); @@ -559,27 +561,27 @@ namespace StardewModdingAPI.Metadata return true; case "terrainfeatures/mushroom_tree": // from Tree - return !ignoreWorld && this.UpdateTreeTextures(Tree.mushroomTree); + return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.mushroomTree)); case "terrainfeatures/tree_palm": // from Tree - return !ignoreWorld && this.UpdateTreeTextures(Tree.palmTree); + return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.palmTree)); case "terrainfeatures/tree1_fall": // from Tree case "terrainfeatures/tree1_spring": // from Tree case "terrainfeatures/tree1_summer": // from Tree case "terrainfeatures/tree1_winter": // from Tree - return !ignoreWorld && this.UpdateTreeTextures(Tree.bushyTree); + return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.bushyTree)); case "terrainfeatures/tree2_fall": // from Tree case "terrainfeatures/tree2_spring": // from Tree case "terrainfeatures/tree2_summer": // from Tree case "terrainfeatures/tree2_winter": // from Tree - return !ignoreWorld && this.UpdateTreeTextures(Tree.leafyTree); + return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.leafyTree)); case "terrainfeatures/tree3_fall": // from Tree case "terrainfeatures/tree3_spring": // from Tree case "terrainfeatures/tree3_winter": // from Tree - return !ignoreWorld && this.UpdateTreeTextures(Tree.pineTree); + return changed | (!ignoreWorld && this.UpdateTreeTextures(Tree.pineTree)); } /**** @@ -588,25 +590,29 @@ namespace StardewModdingAPI.Metadata if (!ignoreWorld) { // dynamic textures - if (assetName.StartsWith("animals/cat")) - return this.UpdatePetOrHorseSprites<Cat>(assetName); - if (assetName.StartsWith("animals/dog")) - return this.UpdatePetOrHorseSprites<Dog>(assetName); if (assetName.IsDirectlyUnderPath("Animals")) - return this.UpdateFarmAnimalSprites(assetName); + { + if (assetName.StartsWith("animals/cat")) + return changed | this.UpdatePetOrHorseSprites<Cat>(assetName); + + if (assetName.StartsWith("animals/dog")) + return changed | this.UpdatePetOrHorseSprites<Dog>(assetName); + + return changed | this.UpdateFarmAnimalSprites(assetName); + } if (assetName.IsDirectlyUnderPath("Buildings")) - return this.UpdateBuildings(assetName); + return changed | this.UpdateBuildings(assetName); if (assetName.StartsWith("LooseSprites/Fence")) - return this.UpdateFenceTextures(assetName); + return changed | this.UpdateFenceTextures(assetName); // dynamic data if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) - return this.UpdateNpcDialogue(assetName); + return changed | this.UpdateNpcDialogue(assetName); if (assetName.IsDirectlyUnderPath("Characters/schedules")) - return this.UpdateNpcSchedules(assetName); + return changed | this.UpdateNpcSchedules(assetName); } return false; @@ -960,7 +966,7 @@ namespace StardewModdingAPI.Metadata { // get suspension bridges field var field = this.Reflection.GetField<IEnumerable<SuspensionBridge>?>(location, nameof(IslandNorth.suspensionBridges), required: false); - // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- field is nullable when required: false + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- field is nullable when required: false if (field == null || !typeof(IEnumerable<SuspensionBridge>).IsAssignableFrom(field.FieldInfo.FieldType)) continue; diff --git a/src/SMAPI/Translation.cs b/src/SMAPI/Translation.cs index 01cb92b2..5cc119d9 100644 --- a/src/SMAPI/Translation.cs +++ b/src/SMAPI/Translation.cs @@ -121,7 +121,7 @@ namespace StardewModdingAPI /// <summary>Get a string representation of the given translation.</summary> /// <param name="translation">The translation key.</param> /// <remarks><strong>Limitation with nullable reference types: if there's no text and you disabled the fallback via <see cref="UsePlaceholder"/>, this will return null but the return value will still be marked non-nullable.</strong></remarks> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The null check is required due to limitations in nullable type annotations (see remarks).")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The null check is required due to limitations in nullable type annotations (see remarks).")] public static implicit operator string(Translation translation) { return translation?.ToString()!; diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index 1d4e4489..06ee8b91 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -250,7 +250,7 @@ namespace StardewModdingAPI.Utilities /// <param name="year">The year.</param> /// <param name="allowDayZero">Whether to allow 0 spring Y1 as a valid date.</param> /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> - [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The nullability is validated in this constructor.")] + [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract", Justification = "The nullability is validated in this constructor.")] private SDate(int day, string season, int year, bool allowDayZero) { season = season?.Trim().ToLowerInvariant()!; // null-checked below @@ -278,11 +278,11 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether a date represents 0 spring Y1, which is the date during the in-game intro.</summary> /// <param name="day">The day of month.</param> - /// <param name="season">The season name.</param> + /// <param name="season">The normalized season name.</param> /// <param name="year">The year.</param> private bool IsDayZero(int day, string season, int year) { - return day == 0 && season?.Trim().ToLower() == "spring" && year == 1; + return day == 0 && season == "spring" && year == 1; } /// <summary>Get the day of week for a given date.</summary> |