diff --git a/.gitignore b/.gitignore index f2d50778..7e0c1e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ _ReSharper*/ **/packages/* *.nuget.props *.nuget.targets + +# sensitive files +appsettings.Development.json diff --git a/build/GlobalAssemblyInfo.cs b/build/GlobalAssemblyInfo.cs index 196d67c5..9af704e0 100644 --- a/build/GlobalAssemblyInfo.cs +++ b/build/GlobalAssemblyInfo.cs @@ -2,5 +2,5 @@ using System.Reflection; using System.Runtime.InteropServices; [assembly: ComVisible(false)] -[assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyFileVersion("2.0.0.0")] +[assembly: AssemblyVersion("2.1.0.0")] +[assembly: AssemblyFileVersion("2.1.0.0")] diff --git a/build/common.targets b/build/common.targets index ee138524..aa11344e 100644 --- a/build/common.targets +++ b/build/common.targets @@ -78,7 +78,7 @@ - + @@ -89,10 +89,10 @@ - - - - + + + + diff --git a/docs/release-notes.md b/docs/release-notes.md index 99e771ce..1a9e4681 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,4 +1,32 @@ # Release notes +## 2.1 +* For players: + * Added a log parser at [log.smapi.io](https://log.smapi.io). + * Added better Steam instructions to the SMAPI installer. + * Renamed the bundled _TrainerMod_ to _ConsoleCommands_ to make its purpose clearer. + * Removed the game's test messages from the console log. + * Improved update-check errors when playing offline. + * Fixed compatibility check for players with Stardew Valley 1.08. + * Fixed `player_setlevel` command not setting XP too. + +* For modders: + * The reflection API now works with public code to simplify mod integrations. + * The content API now lets you invalidated multiple assets at once. + * The `InputEvents` have been improved: + * Added `e.IsActionButton` and `e.IsUseToolButton`. + * Added `ToSButton()` extension for the game's `Game1.options` button type. + * Deprecated `e.IsClick`, which is limited and unclear. Use `IsActionButton` or `IsUseToolButton` instead. + * Fixed `e.SuppressButton()` not correctly suppressing keyboard buttons. + * Fixed `e.IsClick` (now `e.IsActionButton`) ignoring custom key bindings. + * `SemanticVersion` can now be constructed from a `System.Version`. + * Fixed reflection API blocking access to some non-SMAPI members. + * Fixed content API allowing absolute paths as asset keys. + * Fixed content API failing to load custom map tilesheets that aren't preloaded. + * Fixed content API incorrectly detecting duplicate loaders when a mod implements `IAssetLoader` directly. + +* For SMAPI developers: + * Added the installer version and platform to the installer window title to simplify troubleshooting. + ## 2.0 ### Release highlights * **Mod update checks** @@ -18,7 +46,7 @@ SMAPI 2.0 adds several features to enable new kinds of mods (see [API documentation](https://stardewvalleywiki.com/Modding:SMAPI_APIs)). - The **content API** lets you edit, inject, and reload XNB data loaded by the game at any time. This let SMAPI mods do + The **content API** lets you edit, inject, and reload XNB data loaded by the game at any time. This lets SMAPI mods do anything previously only possible with XNB mods, and enables new mod scenarios not possible with XNB mods (e.g. seasonal textures, NPC clothing that depend on the weather or location, etc). diff --git a/docs/technical-docs.md b/docs/technical-docs.md index d37d327d..37ec7f69 100644 --- a/docs/technical-docs.md +++ b/docs/technical-docs.md @@ -3,16 +3,25 @@ This file provides more technical documentation about SMAPI. If you only want to use or create mods, this section isn't relevant to you; see the main README to use or create mods. -## Contents -* [Development](#development) - * [Compiling from source](#compiling-from-source) - * [Debugging a local build](#debugging-a-local-build) - * [Preparing a release](#preparing-a-release) -* [Customisation](#customisation) - * [Configuration file](#configuration-file) - * [Command-line arguments](#command-line-arguments) - * [Compile flags](#compile-flags) +# Contents +* [SMAPI](#smapi) + * [Development](#development) + * [Compiling from source](#compiling-from-source) + * [Debugging a local build](#debugging-a-local-build) + * [Preparing a release](#preparing-a-release) + * [Customisation](#customisation) + * [Configuration file](#configuration-file) + * [Command-line arguments](#command-line-arguments) + * [Compile flags](#compile-flags) +* [SMAPI web services](#smapi-web-services) + * [Overview](#overview) + * [Log parser](#log-parser) + * [Mods API](#mods-api) + * [Development](#development-2) + * [Local development](#local-development) + * [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk) +# SMAPI ## Development ### Compiling from source Using an official SMAPI release is recommended for most users. @@ -135,3 +144,81 @@ SMAPI uses a small number of conditional compilation constants, which you can se flag | purpose ---- | ------- `SMAPI_FOR_WINDOWS` | Indicates that SMAPI is being compiled on Windows for players on Windows. Set automatically in `crossplatform.targets`. + + +# SMAPI web services +## Overview +The `StardewModdingAPI.Web` project provides an API and web UI hosted at `*.smapi.io`. + +### Log parser +The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are +persisted in a compressed form to Pastebin. + +The log parser lives at https://log.smapi.io. + +### Mods API +The mods API provides version info for mods hosted by Chucklefish, GitHub, or Nexus Mods. It's used +by SMAPI to perform update checks. The `{version}` URL token is the version of SMAPI making the +request; it doesn't do anything currently, but lets us version breaking changes if needed. + +Each mod is identified by a repository key and unique identifier (like `nexus:541`). The following +repositories are supported: + +key | repository +------------- | ---------- +`chucklefish` | A mod page on the [Chucklefish mod site](https://community.playstarbound.com/resources/categories/22), identified by the mod ID in the page URL. +`github` | A repository on [GitHub](https://github.com), identified by its owner and repository name (like `Zoryn4163/SMAPI-Mods`). This checks the version of the latest repository release. +`nexus` | A mod page on [Nexus Mods](https://www.nexusmods.com/stardewvalley), identified by the mod ID in the page URL. + + +The API accepts either `GET` or `POST` for convenience: +> ``` +>GET https://api.smapi.io/v2.0/mods?modKeys=nexus:541,chucklefish:4228 +>``` + +>``` +>POST https://api.smapi.io/v2.0/mods +>{ +> "ModKeys": [ "nexus:541", "chucklefish:4228" ] +>} +>``` + +It returns a response like this: +>``` +>{ +> "chucklefish:4228": { +> "name": "Entoarox Framework", +> "version": "1.8.0", +> "url": "https://community.playstarbound.com/resources/4228" +> }, +> "nexus:541": { +> "name": "Lookup Anything", +> "version": "1.16", +> "url": "http://www.nexusmods.com/stardewvalley/mods/541" +> } +>} +>``` + +## Development +### Local development +`StardewModdingAPI.Web` is a regular ASP.NET MVC Core app, so you can just launch it from within +Visual Studio to run a local version. + +There are two differences when it's run locally: all endpoints use HTTP instead of HTTPS, and the +subdomain portion becomes a route (e.g. `log.smapi.io` → `localhost:59482/log`). + +Before running it locally, you need to enter your credentials in the `appsettings.Development.json` +file. See the next section for a description of each setting. This file is listed in `.gitignore` +to prevent accidentally committing credentials. + +### Deploying to Amazon Beanstalk +The app can be deployed to a standard Amazon Beanstalk IIS environment. When creating the +environment, make sure to specify the following environment properties: + +property name | description +------------------------------- | ----------------- +`LogParser:PastebinDevKey` | The [Pastebin developer key](https://pastebin.com/api#1) used to authenticate with the Pastebin API. +`LogParser:PastebinUserKey` | The [Pastebin user key](https://pastebin.com/api#8) used to authenticate with the Pastebin API. Can be left blank to post anonymously. +`LogParser:SectionUrl` | The root URL of the log page, like `https://log.smapi.io/`. +`ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info. +`ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info. diff --git a/src/SMAPI.Common/SemanticVersionImpl.cs b/src/SMAPI.Common/SemanticVersionImpl.cs index 193d23f9..53cf5a21 100644 --- a/src/SMAPI.Common/SemanticVersionImpl.cs +++ b/src/SMAPI.Common/SemanticVersionImpl.cs @@ -48,6 +48,19 @@ namespace StardewModdingAPI.Common this.Tag = this.GetNormalisedTag(tag); } + /// Construct an instance. + /// The assembly version. + /// The is null. + public SemanticVersionImpl(Version version) + { + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version can't be null."); + + this.Major = version.Major; + this.Minor = version.Minor; + this.Patch = version.Build; + } + /// Construct an instance. /// The semantic version string. /// The is null. diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 1a132e54..b5c2735b 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Threading; using Microsoft.Win32; using StardewModdingApi.Installer.Enums; +using StardewModdingAPI.Common; namespace StardewModdingApi.Installer { @@ -97,6 +98,7 @@ namespace StardewModdingApi.Installer // obsolete yield return GetInstallPath("Mods/.cache"); // 1.3-1.4 + yield return GetInstallPath("Mods/TrainerMod"); // *–2.0 (renamed to ConsoleCommands) yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8 yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 if (modsDir.Exists) @@ -135,6 +137,13 @@ namespace StardewModdingApi.Installer /// public void Run(string[] args) { + /**** + ** Get platform & set window title + ****/ + Platform platform = this.DetectPlatform(); + Console.Title = $"SMAPI {new SemanticVersionImpl(this.GetType().Assembly.GetName().Version)} installer on {platform}"; + Console.WriteLine(); + /**** ** read command-line arguments ****/ @@ -159,10 +168,6 @@ namespace StardewModdingApi.Installer /**** ** collect details ****/ - // get platform - Platform platform = this.DetectPlatform(); - this.PrintDebug($"Platform: {(platform == Platform.Windows ? "Windows" : "Linux or Mac")}."); - // get game path DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, gamePathArg); if (installDir == null) @@ -182,7 +187,9 @@ namespace StardewModdingApi.Installer unixLauncher = Path.Combine(installDir.FullName, "StardewValley"), unixLauncherBackup = Path.Combine(installDir.FullName, "StardewValley-original") }; - this.PrintDebug($"Install path: {installDir}."); + + // show output + Console.WriteLine($"Your game folder: {installDir}."); /**** ** validate assumptions @@ -340,22 +347,26 @@ namespace StardewModdingApi.Installer this.InteractivelyRemoveAppDataMods(platform, modsDir, packagedModsDir); } Console.WriteLine(); + Console.WriteLine(); /**** - ** exit + ** final instructions ****/ - this.PrintColor("Done!", ConsoleColor.DarkGreen); if (platform == Platform.Windows) { - this.PrintColor( - action == ScriptAction.Install - ? "Don't forget to launch StardewModdingAPI.exe instead of the normal game executable. See the readme.txt for details." - : "If you manually changed shortcuts or Steam to launch SMAPI, don't forget to change those back.", - ConsoleColor.DarkGreen - ); + if (action == ScriptAction.Install) + { + this.PrintColor("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):", ConsoleColor.DarkGreen); + this.PrintColor($" \"{Path.Combine(installDir.FullName, "StardewModdingAPI.exe")}\" %command%", ConsoleColor.DarkGreen); + Console.WriteLine(); + this.PrintColor("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods.", ConsoleColor.DarkGreen); + } + else + this.PrintColor("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options.", ConsoleColor.DarkGreen); } else if (action == ScriptAction.Install) - this.PrintColor("You can launch the game the same way as before to play with mods.", ConsoleColor.DarkGreen); + this.PrintColor("SMAPI is installed! Launch the game the same way as before to play with mods.", ConsoleColor.DarkGreen); + Console.ReadKey(); } diff --git a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj index f8e368a4..d3a6aa0b 100644 --- a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj +++ b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj @@ -1,4 +1,4 @@ - + @@ -50,6 +50,7 @@ Always + diff --git a/src/SMAPI.Installer/readme.txt b/src/SMAPI.Installer/readme.txt index eb27ac52..a03ad6a4 100644 --- a/src/SMAPI.Installer/readme.txt +++ b/src/SMAPI.Installer/readme.txt @@ -1,14 +1,14 @@ - ___ ___ ___ ___ - / /\ /__/\ / /\ / /\ ___ - / /:/_ | |::\ / /::\ / /::\ / /\ - / /:/ /\ | |:|:\ / /:/\:\ / /:/\:\ / /:/ - / /:/ /::\ __|__|:|\:\ / /:/~/::\ / /:/~/:/ /__/::\ - /__/:/ /:/\:\ /__/::::| \:\ /__/:/ /:/\:\ /__/:/ /:/ \__\/\:\__ - \ \:\/:/~/:/ \ \:\~~\__\/ \ \:\/:/__\/ \ \:\/:/ \ \:\/\ - \ \::/ /:/ \ \:\ \ \::/ \ \::/ \__\::/ - \__\/ /:/ \ \:\ \ \:\ \ \:\ /__/:/ - /__/:/ \ \:\ \ \:\ \ \:\ \__\/ - \__\/ \__\/ \__\/ \__\/ + ___ ___ ___ ___ ___ + / /\ /__/\ / /\ / /\ / /\ + / /:/_ | |::\ / /::\ / /::\ / /:/ + / /:/ /\ | |:|:\ / /:/\:\ / /:/\:\ / /:/ + / /:/ /::\ __|__|:|\:\ / /:/~/::\ / /:/~/:/ / /::\ ___ +/__/:/ /:/\:\ /__/::::| \:\ /__/:/ /:/\:\ /__/:/ /:/ /__/:/\:\ /\ +\ \:\/:/~/:/ \ \:\~~\__\/ \ \:\/:/__\/ \ \:\/:/ \__\/ \:\/:/ + \ \::/ /:/ \ \:\ \ \::/ \ \::/ \__\::/ + \__\/ /:/ \ \:\ \ \:\ \ \:\ / /:/ + /__/:/ \ \:\ \ \:\ \ \:\ /__/:/ + \__\/ \__\/ \__\/ \__\/ \__\/ SMAPI lets you run Stardew Valley with mods. Don't forget to download mods separately. diff --git a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs index 6ef2d568..102d4c3d 100644 --- a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs +++ b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs @@ -5,5 +5,5 @@ using System.Runtime.InteropServices; [assembly: AssemblyDescription("")] [assembly: Guid("ea4f1e80-743f-4a1d-9757-ae66904a196a")] [assembly: ComVisible(false)] -[assembly: AssemblyVersion("2.0.1.0")] -[assembly: AssemblyFileVersion("2.0.1.0")] +[assembly: AssemblyVersion("2.0.2.0")] +[assembly: AssemblyFileVersion("2.0.2.0")] diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index e04f09a7..d2e06e05 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -9,7 +9,7 @@ Properties StardewModdingAPI.ModBuildConfig StardewModdingAPI.ModBuildConfig - v4.6.1 + v4.5 512 diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index b1b228de..91f38a29 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,7 +2,7 @@ Pathoschild.Stardew.ModBuildConfig - 2.0.1 + 2.0.2 Build package for SMAPI mods Pathoschild Pathoschild @@ -23,6 +23,9 @@ 2.0.1: - Fixed mod deploy failing to create subfolders if they don't already exist. + + 2.0.2: + - Fixed compatibility issue on Linux. diff --git a/src/TrainerMod/TrainerMod.cs b/src/SMAPI.Mods.ConsoleCommands/ConsoleCommandsMod.cs similarity index 91% rename from src/TrainerMod/TrainerMod.cs rename to src/SMAPI.Mods.ConsoleCommands/ConsoleCommandsMod.cs index 5db02cd6..96658928 100644 --- a/src/TrainerMod/TrainerMod.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ConsoleCommandsMod.cs @@ -1,14 +1,13 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using StardewModdingAPI; using StardewModdingAPI.Events; -using TrainerMod.Framework.Commands; +using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands; -namespace TrainerMod +namespace StardewModdingAPI.Mods.ConsoleCommands { /// The main entry point for the mod. - public class TrainerMod : Mod + public class ConsoleCommandsMod : Mod { /********* ** Properties @@ -52,7 +51,7 @@ namespace TrainerMod } } - /// Handle a TrainerMod command. + /// Handle a console command. /// The command to invoke. /// The command name specified by the user. /// The command arguments. diff --git a/src/TrainerMod/Framework/Commands/ArgumentParser.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs similarity index 98% rename from src/TrainerMod/Framework/Commands/ArgumentParser.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs index 6bcd3ff8..3ad1e168 100644 --- a/src/TrainerMod/Framework/Commands/ArgumentParser.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs @@ -2,9 +2,8 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using StardewModdingAPI; -namespace TrainerMod.Framework.Commands +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands { /// Provides methods for parsing command-line arguments. internal class ArgumentParser : IReadOnlyList diff --git a/src/TrainerMod/Framework/Commands/ITrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs similarity index 88% rename from src/TrainerMod/Framework/Commands/ITrainerCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs index 3d97e799..a0b739f8 100644 --- a/src/TrainerMod/Framework/Commands/ITrainerCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs @@ -1,8 +1,6 @@ -using StardewModdingAPI; - -namespace TrainerMod.Framework.Commands +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands { - /// A TrainerMod command to register. + /// A console command to register. internal interface ITrainerCommand { /********* diff --git a/src/TrainerMod/Framework/Commands/Other/DebugCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs similarity index 92% rename from src/TrainerMod/Framework/Commands/Other/DebugCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs index 8c6e9f3b..e4010111 100644 --- a/src/TrainerMod/Framework/Commands/Other/DebugCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs @@ -1,7 +1,6 @@ -using StardewModdingAPI; -using StardewValley; +using StardewValley; -namespace TrainerMod.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// A command which sends a debug command to the game. internal class DebugCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Other/ShowDataFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs similarity index 92% rename from src/TrainerMod/Framework/Commands/Other/ShowDataFilesCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs index 367a70c6..54d27185 100644 --- a/src/TrainerMod/Framework/Commands/Other/ShowDataFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs @@ -1,7 +1,6 @@ using System.Diagnostics; -using StardewModdingAPI; -namespace TrainerMod.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// A command which shows the data files. internal class ShowDataFilesCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Other/ShowGameFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs similarity index 92% rename from src/TrainerMod/Framework/Commands/Other/ShowGameFilesCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs index 67fa83a3..0257892f 100644 --- a/src/TrainerMod/Framework/Commands/Other/ShowGameFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs @@ -1,7 +1,6 @@ using System.Diagnostics; -using StardewModdingAPI; -namespace TrainerMod.Framework.Commands.Other +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// A command which shows the game files. internal class ShowGameFilesCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs similarity index 95% rename from src/TrainerMod/Framework/Commands/Player/AddCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index 47840202..81167747 100644 --- a/src/TrainerMod/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -1,11 +1,10 @@ using System; using System.Linq; -using StardewModdingAPI; +using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; using StardewValley; -using TrainerMod.Framework.ItemData; using Object = StardewValley.Object; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which adds an item to the player inventory. internal class AddCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/ListItemTypesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs similarity index 92% rename from src/TrainerMod/Framework/Commands/Player/ListItemTypesCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs index 5f14edbb..34f1760c 100644 --- a/src/TrainerMod/Framework/Commands/Player/ListItemTypesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; -using TrainerMod.Framework.ItemData; +using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which list item types. internal class ListItemTypesCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs similarity index 95% rename from src/TrainerMod/Framework/Commands/Player/ListItemsCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index 7f4f454c..942a50b8 100644 --- a/src/TrainerMod/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using StardewModdingAPI; -using TrainerMod.Framework.ItemData; +using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which list items available to spawn. internal class ListItemsCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs similarity index 97% rename from src/TrainerMod/Framework/Commands/Player/SetColorCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index 28ace0df..5d098593 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetColorCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs @@ -1,8 +1,7 @@ using Microsoft.Xna.Framework; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the color of a player feature. internal class SetColorCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs similarity index 97% rename from src/TrainerMod/Framework/Commands/Player/SetHealthCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs index f64e9035..2e8f6630 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's current health. internal class SetHealthCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetImmunityCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs similarity index 94% rename from src/TrainerMod/Framework/Commands/Player/SetImmunityCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs index 59b28a3c..9c66c4fe 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetImmunityCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's current immunity. internal class SetImmunityCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs similarity index 67% rename from src/TrainerMod/Framework/Commands/Player/SetLevelCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs index b223aa9f..68891267 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetLevelCommand.cs @@ -1,11 +1,33 @@ -using StardewModdingAPI; +using System.Collections.Generic; using StardewValley; +using SFarmer = StardewValley.Farmer; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's current level for a skill. internal class SetLevelCommand : TrainerCommand { + /********* + ** Properties + *********/ + /// The experience points needed to reach each level. + /// Derived from . + private readonly IDictionary LevelExp = new Dictionary + { + [0] = 0, + [1] = 100, + [2] = 380, + [3] = 770, + [4] = 1300, + [5] = 2150, + [6] = 3300, + [7] = 4800, + [8] = 6900, + [9] = 10000, + [10] = 15000 + }; + + /********* ** Public methods *********/ @@ -30,31 +52,37 @@ namespace TrainerMod.Framework.Commands.Player { case "luck": Game1.player.LuckLevel = level; + Game1.player.experiencePoints[SFarmer.luckSkill] = this.LevelExp[level]; monitor.Log($"OK, your luck skill is now {Game1.player.LuckLevel}.", LogLevel.Info); break; case "mining": Game1.player.MiningLevel = level; + Game1.player.experiencePoints[SFarmer.miningSkill] = this.LevelExp[level]; monitor.Log($"OK, your mining skill is now {Game1.player.MiningLevel}.", LogLevel.Info); break; case "combat": Game1.player.CombatLevel = level; + Game1.player.experiencePoints[SFarmer.combatSkill] = this.LevelExp[level]; monitor.Log($"OK, your combat skill is now {Game1.player.CombatLevel}.", LogLevel.Info); break; case "farming": Game1.player.FarmingLevel = level; + Game1.player.experiencePoints[SFarmer.farmingSkill] = this.LevelExp[level]; monitor.Log($"OK, your farming skill is now {Game1.player.FarmingLevel}.", LogLevel.Info); break; case "fishing": Game1.player.FishingLevel = level; + Game1.player.experiencePoints[SFarmer.fishingSkill] = this.LevelExp[level]; monitor.Log($"OK, your fishing skill is now {Game1.player.FishingLevel}.", LogLevel.Info); break; case "foraging": Game1.player.ForagingLevel = level; + Game1.player.experiencePoints[SFarmer.foragingSkill] = this.LevelExp[level]; monitor.Log($"OK, your foraging skill is now {Game1.player.ForagingLevel}.", LogLevel.Info); break; } diff --git a/src/TrainerMod/Framework/Commands/Player/SetMaxHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs similarity index 94% rename from src/TrainerMod/Framework/Commands/Player/SetMaxHealthCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs index 4b9d87dc..f4ae0694 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetMaxHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's maximum health. internal class SetMaxHealthCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs similarity index 94% rename from src/TrainerMod/Framework/Commands/Player/SetMaxStaminaCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs index 3997bb1b..5bce5ea3 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetMaxStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's maximum stamina. internal class SetMaxStaminaCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs similarity index 97% rename from src/TrainerMod/Framework/Commands/Player/SetMoneyCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index 55e069a4..3fc504b1 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's current money. internal class SetMoneyCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetNameCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs similarity index 95% rename from src/TrainerMod/Framework/Commands/Player/SetNameCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs index 3fd4475c..5b1225e8 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetNameCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs @@ -1,7 +1,6 @@ -using StardewModdingAPI; -using StardewValley; +using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's name. internal class SetNameCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetSpeedCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs similarity index 92% rename from src/TrainerMod/Framework/Commands/Player/SetSpeedCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs index 40b87b62..e9693540 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetSpeedCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetSpeedCommand.cs @@ -1,7 +1,6 @@ -using StardewModdingAPI; -using StardewValley; +using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's current added speed. internal class SetSpeedCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs similarity index 97% rename from src/TrainerMod/Framework/Commands/Player/SetStaminaCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs index d44d1370..866c3d22 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits the player's current stamina. internal class SetStaminaCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs similarity index 97% rename from src/TrainerMod/Framework/Commands/Player/SetStyleCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs index 96e34af2..b59be2e5 100644 --- a/src/TrainerMod/Framework/Commands/Player/SetStyleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs @@ -1,7 +1,6 @@ -using StardewModdingAPI; -using StardewValley; +using StardewValley; -namespace TrainerMod.Framework.Commands.Player +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// A command which edits a player style. internal class SetStyleCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/TrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs similarity index 98% rename from src/TrainerMod/Framework/Commands/TrainerCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs index abe9ee41..466b8f6e 100644 --- a/src/TrainerMod/Framework/Commands/TrainerCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; -using StardewModdingAPI; -namespace TrainerMod.Framework.Commands +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands { /// The base implementation for a trainer command. internal abstract class TrainerCommand : ITrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/DownMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs similarity index 91% rename from src/TrainerMod/Framework/Commands/World/DownMineLevelCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs index 4e62cf77..da117006 100644 --- a/src/TrainerMod/Framework/Commands/World/DownMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs @@ -1,8 +1,7 @@ -using StardewModdingAPI; -using StardewValley; +using StardewValley; using StardewValley.Locations; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which moves the player to the next mine level. internal class DownMineLevelCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/FreezeTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs similarity index 97% rename from src/TrainerMod/Framework/Commands/World/FreezeTimeCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs index 13d08398..2627b714 100644 --- a/src/TrainerMod/Framework/Commands/World/FreezeTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which freezes the current time. internal class FreezeTimeCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/SetDayCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs similarity index 95% rename from src/TrainerMod/Framework/Commands/World/SetDayCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs index 54267384..8d6bd759 100644 --- a/src/TrainerMod/Framework/Commands/World/SetDayCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which sets the current day. internal class SetDayCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/SetMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs similarity index 94% rename from src/TrainerMod/Framework/Commands/World/SetMineLevelCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs index 225ec091..1024b7b6 100644 --- a/src/TrainerMod/Framework/Commands/World/SetMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs @@ -1,8 +1,7 @@ using System; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which moves the player to the given mine level. internal class SetMineLevelCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs similarity index 95% rename from src/TrainerMod/Framework/Commands/World/SetSeasonCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs index 96c3d920..897d052f 100644 --- a/src/TrainerMod/Framework/Commands/World/SetSeasonCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which sets the current season. internal class SetSeasonCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs similarity index 95% rename from src/TrainerMod/Framework/Commands/World/SetTimeCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs index c827ea5e..d6c71387 100644 --- a/src/TrainerMod/Framework/Commands/World/SetTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which sets the current time. internal class SetTimeCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/Commands/World/SetYearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs similarity index 94% rename from src/TrainerMod/Framework/Commands/World/SetYearCommand.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs index 760fc170..66abd6dc 100644 --- a/src/TrainerMod/Framework/Commands/World/SetYearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs @@ -1,8 +1,7 @@ using System.Linq; -using StardewModdingAPI; using StardewValley; -namespace TrainerMod.Framework.Commands.World +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// A command which sets the current year. internal class SetYearCommand : TrainerCommand diff --git a/src/TrainerMod/Framework/ItemData/ItemType.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs similarity index 94% rename from src/TrainerMod/Framework/ItemData/ItemType.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs index 423455e9..797d4650 100644 --- a/src/TrainerMod/Framework/ItemData/ItemType.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs @@ -1,4 +1,4 @@ -namespace TrainerMod.Framework.ItemData +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData { /// An item type that can be searched and added to the player through the console. internal enum ItemType diff --git a/src/TrainerMod/Framework/ItemData/SearchableItem.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs similarity index 94% rename from src/TrainerMod/Framework/ItemData/SearchableItem.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs index 146da1a8..3eede413 100644 --- a/src/TrainerMod/Framework/ItemData/SearchableItem.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs @@ -1,6 +1,6 @@ using StardewValley; -namespace TrainerMod.Framework.ItemData +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData { /// A game item with metadata. internal class SearchableItem diff --git a/src/TrainerMod/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs similarity index 98% rename from src/TrainerMod/Framework/ItemRepository.cs rename to src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 96d3159e..b5fe9f2f 100644 --- a/src/TrainerMod/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -1,12 +1,12 @@ using System.Collections.Generic; using Microsoft.Xna.Framework; +using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; using StardewValley; using StardewValley.Objects; using StardewValley.Tools; -using TrainerMod.Framework.ItemData; using SObject = StardewValley.Object; -namespace TrainerMod.Framework +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { /// Provides methods for searching and constructing items. internal class ItemRepository diff --git a/src/SMAPI.Mods.ConsoleCommands/Properties/AssemblyInfo.cs b/src/SMAPI.Mods.ConsoleCommands/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..ac15ec72 --- /dev/null +++ b/src/SMAPI.Mods.ConsoleCommands/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("SMAPI.Mods.ConsoleCommands")] +[assembly: AssemblyDescription("")] +[assembly: Guid("76791e28-b1b5-407c-82d6-50c3e5b7e037")] diff --git a/src/TrainerMod/TrainerMod.csproj b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj similarity index 93% rename from src/TrainerMod/TrainerMod.csproj rename to src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj index cb5ec47e..f228bb25 100644 --- a/src/TrainerMod/TrainerMod.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj @@ -7,8 +7,8 @@ {28480467-1A48-46A7-99F8-236D95225359} Library Properties - TrainerMod - TrainerMod + StardewModdingAPI.Mods.ConsoleCommands + ConsoleCommands v4.5 512 @@ -16,7 +16,7 @@ true full true - $(SolutionDir)\..\bin\Debug\Mods\TrainerMod\ + $(SolutionDir)\..\bin\Debug\Mods\ConsoleCommands\ DEBUG;TRACE prompt 4 @@ -27,7 +27,7 @@ pdbonly true - $(SolutionDir)\..\bin\Release\Mods\TrainerMod\ + $(SolutionDir)\..\bin\Release\Mods\ConsoleCommands\ TRACE prompt 4 @@ -38,6 +38,7 @@ ..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + False @@ -80,7 +81,7 @@ - + diff --git a/src/TrainerMod/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json similarity index 67% rename from src/TrainerMod/manifest.json rename to src/SMAPI.Mods.ConsoleCommands/manifest.json index 22e35bce..664dfabf 100644 --- a/src/TrainerMod/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,5 +1,5 @@ { - "Name": "Trainer Mod", + "Name": "Console Commands", "Author": "SMAPI", "Version": { "MajorVersion": 2, @@ -8,6 +8,6 @@ "Build": null }, "Description": "Adds SMAPI console commands that let you manipulate the game.", - "UniqueID": "SMAPI.TrainerMod", - "EntryDll": "TrainerMod.dll" + "UniqueID": "SMAPI.ConsoleCommands", + "EntryDll": "ConsoleCommands.dll" } diff --git a/src/TrainerMod/packages.config b/src/SMAPI.Mods.ConsoleCommands/packages.config similarity index 100% rename from src/TrainerMod/packages.config rename to src/SMAPI.Mods.ConsoleCommands/packages.config diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index 03cd26c9..d3e0988e 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; using NUnit.Framework; @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Tests.Utilities /**** ** Constructor ****/ - [Test(Description = "Assert that the constructor sets the expected values for all valid versions.")] + [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from a string.")] [TestCase("1.0", ExpectedResult = "1.0")] [TestCase("1.0.0", ExpectedResult = "1.0")] [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Tests.Utilities return new SemanticVersion(input).ToString(); } - [Test(Description = "Assert that the constructor sets the expected values for all valid versions.")] + [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")] [TestCase(1, 0, 0, null, ExpectedResult = "1.0")] [TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")] [TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")] @@ -48,6 +48,22 @@ namespace StardewModdingAPI.Tests.Utilities return version.ToString(); } + [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] + [TestCase(1, 0, 0, ExpectedResult = "1.0")] + [TestCase(1, 2, 3, ExpectedResult = "1.2.3")] + [TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")] + public string Constructor_FromAssemblyVersion(int major, int minor, int patch) + { + // act + ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch)); + + // assert + Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); + Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); + Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); + return version.ToString(); + } + [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] [TestCase(null)] [TestCase("")] @@ -239,6 +255,7 @@ namespace StardewModdingAPI.Tests.Utilities [TestCase("1.06")] [TestCase("1.07")] [TestCase("1.07a")] + [TestCase("1.08")] [TestCase("1.1")] [TestCase("1.11")] [TestCase("1.2")] diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs new file mode 100644 index 00000000..454440bb --- /dev/null +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.Framework.LogParser; +using StardewModdingAPI.Web.ViewModels; + +namespace StardewModdingAPI.Web.Controllers +{ + /// Provides a web UI and API for parsing SMAPI log files. + internal class LogParserController : Controller + { + /********* + ** Properties + *********/ + /// The log parser config settings. + private readonly LogParserConfig Config; + + /// The underlying Pastebin client. + private readonly PastebinClient PastebinClient; + + /// The first bytes in a valid zip file. + /// See . + private const uint GzipLeadBytes = 0x8b1f; + + + /********* + ** Public methods + *********/ + /*** + ** Constructor + ***/ + /// Construct an instance. + /// The log parser config settings. + public LogParserController(IOptions configProvider) + { + // init Pastebin client + this.Config = configProvider.Value; + string version = this.GetType().Assembly.GetName().Version.ToString(3); + string userAgent = string.Format(this.Config.PastebinUserAgent, version); + this.PastebinClient = new PastebinClient(this.Config.PastebinBaseUrl, userAgent, this.Config.PastebinUserKey, this.Config.PastebinDevKey); + } + + /*** + ** Web UI + ***/ + /// Render the log parser UI. + /// The paste ID. + [HttpGet] + [Route("")] + [Route("log")] + [Route("log/{id}")] + public ViewResult Index(string id = null) + { + return this.View("Index", new LogParserModel(this.Config.SectionUrl, id)); + } + + /*** + ** JSON + ***/ + /// Fetch raw text from Pastebin. + /// The Pastebin paste ID. + [HttpGet, Produces("application/json")] + [Route("log/fetch/{id}")] + public async Task GetAsync(string id) + { + GetPasteResponse response = await this.PastebinClient.GetAsync(id); + response.Content = this.DecompressString(response.Content); + return response; + } + + /// Save raw log data. + /// The log content to save. + [HttpPost, Produces("application/json"), AllowLargePosts] + [Route("log/save")] + public async Task PostAsync([FromBody] string content) + { + content = this.CompressString(content); + return await this.PastebinClient.PostAsync(content); + } + + + /********* + ** Private methods + *********/ + /// Compress a string. + /// The text to compress. + /// Derived from . + private string CompressString(string text) + { + // get raw bytes + byte[] buffer = Encoding.UTF8.GetBytes(text); + + // compressed + byte[] compressedData; + using (MemoryStream stream = new MemoryStream()) + { + using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) + zipStream.Write(buffer, 0, buffer.Length); + + stream.Position = 0; + compressedData = new byte[stream.Length]; + stream.Read(compressedData, 0, compressedData.Length); + } + + // prefix length + var zipBuffer = new byte[compressedData.Length + 4]; + Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); + Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); + + // return string representation + return Convert.ToBase64String(zipBuffer); + } + + /// Decompress a string. + /// The compressed text. + /// Derived from . + private string DecompressString(string rawText) + { + // get raw bytes + byte[] zipBuffer; + try + { + zipBuffer = Convert.FromBase64String(rawText); + } + catch + { + return rawText; // not valid base64, wasn't compressed by the log parser + } + + // skip if not gzip + if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes) + return rawText; + + // decompress + using (MemoryStream memoryStream = new MemoryStream()) + { + // read length prefix + int dataLength = BitConverter.ToInt32(zipBuffer, 0); + memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); + + // read data + var buffer = new byte[dataLength]; + memoryStream.Position = 0; + using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + gZipStream.Read(buffer, 0, buffer.Length); + + // return original string + return Encoding.UTF8.GetString(buffer); + } + } + } +} diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs similarity index 97% rename from src/SMAPI.Web/Controllers/ModsController.cs rename to src/SMAPI.Web/Controllers/ModsApiController.cs index a671ddca..a600662c 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -14,8 +14,8 @@ namespace StardewModdingAPI.Web.Controllers { /// Provides an API to perform mod update checks. [Produces("application/json")] - [Route("api/{version:semanticVersion}/[controller]")] - internal class ModsController : Controller + [Route("api/v{version:semanticVersion}/mods")] + internal class ModsApiController : Controller { /********* ** Properties @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Web.Controllers /// Construct an instance. /// The cache in which to store mod metadata. /// The config settings for mod update checks. - public ModsController(IMemoryCache cache, IOptions configProvider) + public ModsApiController(IMemoryCache cache, IOptions configProvider) { ModUpdateCheckConfig config = configProvider.Value; diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs new file mode 100644 index 00000000..68ead3c2 --- /dev/null +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace StardewModdingAPI.Web.Framework +{ + /// A filter which increases the maximum request size for an endpoint. + /// Derived from . + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class AllowLargePostsAttribute : Attribute, IAuthorizationFilter, IOrderedFilter + { + /********* + ** Properties + *********/ + /// The underlying form options. + private readonly FormOptions FormOptions; + + + /********* + ** Accessors + *********/ + /// The attribute order. + public int Order { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public AllowLargePostsAttribute() + { + this.FormOptions = new FormOptions + { + ValueLengthLimit = 200 * 1024 * 1024 // 200MB + }; + } + + /// Called early in the filter pipeline to confirm request is authorized. + /// The authorisation filter context. + public void OnAuthorization(AuthorizationFilterContext context) + { + IFeatureCollection features = context.HttpContext.Features; + IFormFeature formFeature = features.Get(); + + if (formFeature?.Form == null) + { + // Request form has not been read yet, so set the limits + features.Set(new FormFeature(context.HttpContext.Request, this.FormOptions)); + } + } + } +} diff --git a/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs new file mode 100644 index 00000000..b39a3b61 --- /dev/null +++ b/src/SMAPI.Web/Framework/BeanstalkEnvPropsConfigProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Web.Framework +{ + /// Reads configuration values from the AWS Beanstalk environment properties file (if present). + /// This is a workaround for AWS Beanstalk injection not working with .NET Core apps. + internal class BeanstalkEnvPropsConfigProvider : ConfigurationProvider, IConfigurationSource + { + /********* + ** Properties + *********/ + /// The absolute path to the container configuration file on an Amazon EC2 instance. + private const string ContainerConfigPath = @"C:\Program Files\Amazon\ElasticBeanstalk\config\containerconfiguration"; + + + /********* + ** Public methods + *********/ + /// Build the configuration provider for this source. + /// The configuration builder. + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new BeanstalkEnvPropsConfigProvider(); + } + + /// Load the environment properties. + public override void Load() + { + this.Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // get Beanstalk config file + FileInfo file = new FileInfo(BeanstalkEnvPropsConfigProvider.ContainerConfigPath); + if (!file.Exists) + return; + + // parse JSON + JObject jsonRoot = (JObject)JsonConvert.DeserializeObject(File.ReadAllText(file.FullName)); + if (jsonRoot["iis"]?["env"] is JArray jsonProps) + { + foreach (string prop in jsonProps.Values()) + { + string[] parts = prop.Split('=', 2); // key=value + if (parts.Length == 2) + this.Data[parts[0]] = parts[1]; + } + } + } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs new file mode 100644 index 00000000..df5d605d --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/LogParserConfig.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// The config settings for the log parser. + internal class LogParserConfig + { + /********* + ** Accessors + *********/ + /// The root URL for the log parser controller. + public string SectionUrl { get; set; } + + /// The base URL for the Pastebin API. + public string PastebinBaseUrl { get; set; } + + /// The user agent for the Pastebin API client, where {0} is the SMAPI version. + public string PastebinUserAgent { get; set; } + + /// The user key used to authenticate with the Pastebin API. + public string PastebinUserKey { get; set; } + + /// The developer key used to authenticate with the Pastebin API. + public string PastebinDevKey { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index 03de639e..2fb5b97e 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels { /// The config settings for mod update checks. - public class ModUpdateCheckConfig + internal class ModUpdateCheckConfig { /********* ** Accessors diff --git a/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs b/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs new file mode 100644 index 00000000..4f8794db --- /dev/null +++ b/src/SMAPI.Web/Framework/LogParser/GetPasteResponse.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.LogParser +{ + /// The response for a get-paste request. + internal class GetPasteResponse + { + /// Whether the log was successfully fetched. + public bool Success { get; set; } + + /// The fetched paste content (if is true). + public string Content { get; set; } + + /// The error message (if saving failed). + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs new file mode 100644 index 00000000..738330d3 --- /dev/null +++ b/src/SMAPI.Web/Framework/LogParser/PastebinClient.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Web.Framework.LogParser +{ + /// An API client for Pastebin. + internal class PastebinClient : IDisposable + { + /********* + ** Properties + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + /// The user key used to authenticate with the Pastebin API. + private readonly string UserKey; + + /// The developer key used to authenticate with the Pastebin API. + private readonly string DevKey; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The base URL for the Pastebin API. + /// The user agent for the API client. + /// The user key used to authenticate with the Pastebin API. + /// The developer key used to authenticate with the Pastebin API. + public PastebinClient(string baseUrl, string userAgent, string userKey, string devKey) + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + this.UserKey = userKey; + this.DevKey = devKey; + } + + /// Fetch a saved paste. + /// The paste ID. + public async Task GetAsync(string id) + { + try + { + // get from API + string content = await this.Client + .GetAsync($"raw/{id}") + .AsString(); + + // handle Pastebin errors + if (string.IsNullOrWhiteSpace(content)) + return new GetPasteResponse { Error = "Received an empty response from Pastebin." }; + if (content.StartsWith(" PostAsync(string content) + { + try + { + // validate + if (string.IsNullOrWhiteSpace(content)) + return new SavePasteResponse { Error = "The log content can't be empty." }; + + // post to API + string response = await this.Client + .PostAsync("api/api_post.php") + .WithBodyContent(new FormUrlEncodedContent(new Dictionary + { + ["api_option"] = "paste", + ["api_user_key"] = this.UserKey, + ["api_dev_key"] = this.DevKey, + ["api_paste_private"] = "1", // unlisted + ["api_paste_name"] = $"SMAPI log {DateTime.UtcNow:s}", + ["api_paste_expire_date"] = "1W", // one week + ["api_paste_code"] = content + })) + .AsString(); + + // handle Pastebin errors + if (string.IsNullOrWhiteSpace(response)) + return new SavePasteResponse { Error = "Received an empty response from Pastebin." }; + if (response.StartsWith("Bad API request")) + return new SavePasteResponse { Error = response }; + if (!response.Contains("/")) + return new SavePasteResponse { Error = $"Received an unknown response: {response}" }; + + // return paste ID + string pastebinID = response.Split("/").Last(); + return new SavePasteResponse { Success = true, ID = pastebinID }; + } + catch (Exception ex) + { + return new SavePasteResponse { Success = false, Error = ex.ToString() }; + } + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs b/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs new file mode 100644 index 00000000..1c0960a4 --- /dev/null +++ b/src/SMAPI.Web/Framework/LogParser/SavePasteResponse.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.LogParser +{ + /// The response for a save-log request. + internal class SavePasteResponse + { + /// Whether the log was successfully saved. + public bool Success { get; set; } + + /// The saved paste ID (if is true). + public string ID { get; set; } + + /// The error message (if saving failed). + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs new file mode 100644 index 00000000..d6a56bb7 --- /dev/null +++ b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRedirectToHttpsRule.cs @@ -0,0 +1,62 @@ +using System; +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RewriteRules +{ + /// Redirect requests to HTTPS. + /// Derived from and . + internal class ConditionalRedirectToHttpsRule : IRule + { + /********* + ** Properties + *********/ + /// A predicate which indicates when the rule should be applied. + private readonly Func ShouldRewrite; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A predicate which indicates when the rule should be applied. + public ConditionalRedirectToHttpsRule(Func shouldRewrite = null) + { + this.ShouldRewrite = shouldRewrite ?? (req => true); + } + + /// Applies the rule. Implementations of ApplyRule should set the value for (defaults to RuleResult.ContinueRules). + /// The rewrite context. + public void ApplyRule(RewriteContext context) + { + HttpRequest request = context.HttpContext.Request; + + // check condition + if (this.IsSecure(request) || !this.ShouldRewrite(request)) + return; + + // redirect request + HttpResponse response = context.HttpContext.Response; + response.StatusCode = (int)HttpStatusCode.RedirectKeepVerb; + response.Headers["Location"] = new StringBuilder() + .Append("https://") + .Append(request.Host.Host) + .Append(request.PathBase) + .Append(request.Path) + .Append(request.QueryString) + .ToString(); + context.Result = RuleResult.EndResponse; + } + + /// Get whether the request was received over HTTPS. + /// The request to check. + public bool IsSecure(HttpRequest request) + { + return + request.IsHttps // HTTPS to server + || string.Equals(request.Headers["x-forwarded-proto"], "HTTPS", StringComparison.OrdinalIgnoreCase); // HTTPS to AWS load balancer + } + } +} diff --git a/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs new file mode 100644 index 00000000..920632ab --- /dev/null +++ b/src/SMAPI.Web/Framework/RewriteRules/ConditionalRewriteSubdomainRule.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RewriteRules +{ + /// Rewrite requests to prepend the subdomain portion (if any) to the path. + /// Derived from . + internal class ConditionalRewriteSubdomainRule : IRule + { + /********* + ** Accessors + *********/ + /// A predicate which indicates when the rule should be applied. + private readonly Func ShouldRewrite; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A predicate which indicates when the rule should be applied. + public ConditionalRewriteSubdomainRule(Func shouldRewrite = null) + { + this.ShouldRewrite = shouldRewrite ?? (req => true); + } + + /// Applies the rule. Implementations of ApplyRule should set the value for (defaults to RuleResult.ContinueRules). + /// The rewrite context. + public void ApplyRule(RewriteContext context) + { + HttpRequest request = context.HttpContext.Request; + + // check condition + if (!this.ShouldRewrite(request)) + return; + + // get host parts + string host = request.Host.Host; + string[] parts = host.Split('.'); + if (parts.Length < 2) + return; + + // prepend to path + request.Path = $"/{parts[0]}{request.Path}"; + } + } +} diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs new file mode 100644 index 00000000..0719e311 --- /dev/null +++ b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs @@ -0,0 +1,61 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; + +namespace StardewModdingAPI.Web.Framework.RewriteRules +{ + /// Redirect requests to an external URL if they match a condition. + internal class RedirectToUrlRule : IRule + { + /********* + ** Properties + *********/ + /// A predicate which indicates when the rule should be applied. + private readonly Func ShouldRewrite; + + /// The new URL to which to redirect. + private readonly string NewUrl; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A predicate which indicates when the rule should be applied. + /// The new URL to which to redirect. + public RedirectToUrlRule(Func shouldRewrite, string url) + { + this.ShouldRewrite = shouldRewrite ?? (req => true); + this.NewUrl = url; + } + + /// Construct an instance. + /// A case-insensitive regex to match against the path. + /// The external URL. + public RedirectToUrlRule(string pathRegex, string url) + { + Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled); + this.ShouldRewrite = req => req.Path.HasValue && regex.IsMatch(req.Path.Value); + this.NewUrl = url; + } + + /// Applies the rule. Implementations of ApplyRule should set the value for (defaults to RuleResult.ContinueRules). + /// The rewrite context. + public void ApplyRule(RewriteContext context) + { + HttpRequest request = context.HttpContext.Request; + + // check condition + if (!this.ShouldRewrite(request)) + return; + + // redirect request + HttpResponse response = context.HttpContext.Response; + response.StatusCode = (int)HttpStatusCode.Redirect; + response.Headers["Location"] = this.NewUrl; + context.Result = RuleResult.EndResponse; + } + } +} diff --git a/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs b/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs deleted file mode 100644 index 5a56844f..00000000 --- a/src/SMAPI.Web/Framework/RewriteSubdomainRule.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using Microsoft.AspNetCore.Rewrite; - -namespace StardewModdingAPI.Web.Framework -{ - /// Rewrite requests to prepend the subdomain portion (if any) to the path. - /// Derived from . - internal class RewriteSubdomainRule : IRule - { - /// Applies the rule. Implementations of ApplyRule should set the value for (defaults to RuleResult.ContinueRules). - /// The rewrite context. - public void ApplyRule(RewriteContext context) - { - context.Result = RuleResult.ContinueRules; - - // get host parts - string host = context.HttpContext.Request.Host.Host; - string[] parts = host.Split('.'); - - // validate - if (parts.Length < 2) - return; - if (parts.Length < 3 && !"localhost".Equals(parts[1], StringComparison.InvariantCultureIgnoreCase)) - return; - - // prepend to path - context.HttpContext.Request.Path = $"/{parts[0]}{context.HttpContext.Request.Path}"; - } - } -} diff --git a/src/SMAPI.Web/Properties/launchSettings.json b/src/SMAPI.Web/Properties/launchSettings.json index a0760365..e485e4e3 100644 --- a/src/SMAPI.Web/Properties/launchSettings.json +++ b/src/SMAPI.Web/Properties/launchSettings.json @@ -11,19 +11,10 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/1.0/mods?modKeys=nexus:541,chucklefish:4228,github:Zoryn4163/SMAPI-Mods", + "launchUrl": "log", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "Dewdrop": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "api/1.0/mods?modKeys=nexus:541,chucklefish:4228,github:Zoryn4163/SMAPI-Mods", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "http://localhost:59483" } } } diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index eaf14983..0ea9f7ee 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.Framework.RewriteRules; namespace StardewModdingAPI.Web { @@ -30,10 +31,9 @@ namespace StardewModdingAPI.Web { this.Configuration = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) - .AddEnvironmentVariables() .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) - .AddEnvironmentVariables() + .Add(new BeanstalkEnvPropsConfigProvider()) //.AddEnvironmentVariables() .Build(); } @@ -43,6 +43,7 @@ namespace StardewModdingAPI.Web { services .Configure(this.Configuration.GetSection("ModUpdateCheck")) + .Configure(this.Configuration.GetSection("LogParser")) .Configure(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddMemoryCache() .AddMvc() @@ -63,7 +64,33 @@ namespace StardewModdingAPI.Web loggerFactory.AddConsole(this.Configuration.GetSection("Logging")); loggerFactory.AddDebug(); app - .UseRewriter(new RewriteOptions().Add(new RewriteSubdomainRule())) // convert subdomain.smapi.io => smapi.io/subdomain for routing + .UseCors(policy => policy + .AllowAnyHeader() + .AllowAnyMethod() + .WithOrigins("https://smapi.io", "https://*.smapi.io", "https://*.edge.smapi.io") + .SetIsOriginAllowedToAllowWildcardSubdomains() + ) + .UseRewriter(new RewriteOptions() + // redirect to HTTPS (except API for Linux/Mac Mono compatibility) + .Add(new ConditionalRedirectToHttpsRule( + shouldRewrite: req => + req.Host.Host != "localhost" + && !req.Path.StartsWithSegments("/api") + )) + + // convert subdomain.smapi.io => smapi.io/subdomain for routing + .Add(new ConditionalRewriteSubdomainRule( + shouldRewrite: req => + req.Host.Host != "localhost" + && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.")) + && !req.Path.StartsWithSegments("/content") + )) + + // shortcut redirects + .Add(new RedirectToUrlRule("^/docs$", "https://stardewvalleywiki.com/Modding:Index")) + .Add(new RedirectToUrlRule("^/install$", "https://stardewvalleywiki.com/Modding:Installing_SMAPI")) + ) + .UseStaticFiles() // wwwroot folder .UseMvc(); } } diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs new file mode 100644 index 00000000..b5b3b14c --- /dev/null +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -0,0 +1,31 @@ +namespace StardewModdingAPI.Web.ViewModels +{ + /// The view model for the log parser page. + public class LogParserModel + { + /********* + ** Accessors + *********/ + /// The root URL for the log parser controller. + public string SectionUrl { get; set; } + + /// The paste ID. + public string PasteID { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public LogParserModel() { } + + /// Construct an instance. + /// The root URL for the log parser controller. + /// The paste ID. + public LogParserModel(string sectionUrl, string pasteID) + { + this.SectionUrl = sectionUrl; + this.PasteID = pasteID; + } + } +} diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml new file mode 100644 index 00000000..49688d78 --- /dev/null +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -0,0 +1,119 @@ +@{ + ViewData["Title"] = "SMAPI log parser"; +} +@model StardewModdingAPI.Web.ViewModels.LogParserModel +@section Head { + + + + + +} + +@********* +** Intro +*********@ +

This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.

+ + +@if (Model.PasteID != null) +{ +

Parsed log

+
    +
  • TRACE
  • +
  • DEBUG
  • +
  • INFO
  • +
  • ALERT
  • +
  • WARN
  • +
  • ERROR
  • +
  • Click tabs to toggle message visibility
  • +
+} +
+ + + + + +
+ +
diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml new file mode 100644 index 00000000..547a8178 --- /dev/null +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -0,0 +1,30 @@ + + + + + @ViewData["Title"] - SMAPI.io + + @RenderSection("Head", required: false) + + + +
+
+

@ViewData["Title"]

+ @RenderBody() +
+ +
+ + diff --git a/src/SMAPI.Web/Views/_ViewStart.cshtml b/src/SMAPI.Web/Views/_ViewStart.cshtml new file mode 100644 index 00000000..a5f10045 --- /dev/null +++ b/src/SMAPI.Web/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index fa8ce71a..87c35ca9 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -1,4 +1,13 @@ -{ +/* + + + This file is committed to source control with the default settings, but added to .gitignore to + avoid accidentally committing login details. + + + +*/ +{ "Logging": { "IncludeScopes": false, "LogLevel": { @@ -6,5 +15,14 @@ "System": "Information", "Microsoft": "Information" } + }, + "ModUpdateCheck": { + "GitHubUsername": null, + "GitHubPassword": null + }, + "LogParser": { + "SectionUrl": "http://localhost:59482/log/", + "PastebinUserKey": null, + "PastebinDevKey": null } } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 852f6f71..eb6ecc9b 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -1,3 +1,11 @@ +/* + + + This contains the default settings for the web app. Login credentials and contextual settings are + configured via appsettings.Development.json locally, or environment properties in AWS. + + +*/ { "Logging": { "IncludeScopes": false, @@ -19,12 +27,19 @@ "GitHubBaseUrl": "https://api.github.com", "GitHubReleaseUrlFormat": "repos/{0}/releases/latest", "GitHubAcceptHeader": "application/vnd.github.v3+json", - "GitHubUsername": null, /* set via environment properties */ - "GitHubPassword": null, /* set via environment properties */ + "GitHubUsername": null, // see top note + "GitHubPassword": null, // see top note "NexusKey": "Nexus", "NexusUserAgent": "Nexus Client v0.63.15", "NexusBaseUrl": "http://www.nexusmods.com/stardewvalley", "NexusModUrlFormat": "mods/{0}" + }, + "LogParser": { + "SectionUrl": null, // see top note + "PastebinBaseUrl": "https://pastebin.com/", + "PastebinUserAgent": "SMAPI/{0} (+https://github.com/Pathoschild/SMAPI)", + "PastebinUserKey": null, // see top note + "PastebinDevKey": null // see top note } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css new file mode 100644 index 00000000..975e9c2e --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -0,0 +1,348 @@ +.mod-repeat { + font-size: 8pt; +} + +.template { + display: none; +} + +.popup, #uploader { + position: fixed; + top: 0px; + left: 0px; + right: 0px; + bottom: 0; + background-color: rgba(0, 0, 0, .33); + z-index: 2; + display: none; + padding: 5px; +} + +#upload-button { + background: #ccf; + border: 1px solid #000088; +} + +#upload-button { + background: #eef; +} + + +#uploader:after { + content: attr(data-text); + display: block; + width: 100px; + height: 24px; + line-height: 25px; + border: 1px solid #000; + background: #fff; + position: absolute; + top: 50%; + left: 50%; + margin: -12px -50px 0 0; + font-size: 18px; + font-weight: bold; + text-align: center; + border-radius: 5px; +} + +.popup h1 { + position: absolute; + top: 10%; + left: 50%; + margin-left: -150px; + text-align: center; + width: 300px; + border: 1px solid #008; + border-radius: 5px; + background: #fff; + font-family: sans-serif; + font-size: 40px; + margin-top: -25px; + z-index: 10; + border-bottom: 0; +} + +.frame { + margin: auto; + margin-top: 25px; + padding: 2em; + position: absolute; + top: 10%; + left: 10%; + right: 10%; + bottom: 10%; + padding-bottom: 30px; + background: #FFF; + border-radius: 5px; + border: 1px solid #008; +} + +input[type="button"] { + font-size: 20px; + border-radius: 5px; + outline: none; + box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 0, .2); + cursor: pointer; +} + +#input[type="button"]:hover { + background-color: #fee; +} + +#cancel, #closeraw { + border: 1px solid #880000; + background-color: #fcc; +} + +#submit { + border: 1px solid #008800; + background-color: #cfc; +} + +#submit:hover { + background-color: #efe; +} + +#input, #dataraw { + width: 100%; + height: 30em; + max-height: 70%; + margin: auto; + box-sizing: border-box; + border-radius: 5px; + border: 1px solid #000088; + outline: none; + box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); +} + +.color-red { + color: red; +} + +.color-green { + color: green; +} + +#tabs { + border-bottom: 0; + display: block; + margin: 0; + padding: 0; + background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(210, 235, 249, 1) 100%); +} + +#tabs li { + margin: 5px 1px 0 0; + height: 25px; + display: inline-block; + width: 75px; + border: 1px solid #000000; + border-bottom: 0; + border-radius: 5px 5px 0 0; + text-align: center; + font-family: monospace; + font-size: 18px; + cursor: pointer; + font-weight: bold; + color: #000; + text-shadow: 0px 0px 2px #fff; + border-color: #880000; + background-color: #fcc; + box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 0, .2); +} + +#tabs li:hover { + background-color: #fee; +} + +#tabs li:first-child { + margin-left: 5px; +} + +#tabs li.active { + background: #cfc; + border-color: #008800; +} + +#tabs li.active:hover { + background: #efe; +} + +#tabs li.notice { + color: #000000; + background: transparent; + border: 0; + padding-top: 1px; + font-size: 13px; + font-weight: normal; + width: auto; + margin-left: 5px; + cursor: default; + box-shadow: none; + font-style: italic; +} + +#output { + border-top: 1px solid #888; + padding: 10px; + overflow: auto; + font-family: monospace; +} + +#output > * { + display: block; +} + +#output.trace .trace, +#output.debug .debug, +#output.info .info, +#output.alert .alert, +#output.warn .warn, +#output.error .error { + display: none; +} + +#output .trace { + color: #999; +} + +#output .debug { + color: #595959; +} + +#output .info { + color: #000 +} + +#output .alert { + color: #b0b; +} + +#output .warn { + color: #f80 +} + +#output .error { + color: #f00 +} + +#output .always { + font-weight: bold; + border-bottom: 1px dashed #888888; + padding-bottom: 10px; + margin-bottom: 5px; +} + +caption { + text-align: left; + padding-top: 2px; +} + +#log { + border-spacing: 0; +} + +#log tr { + background: #fff; +} + +#log td { + padding: 0 1px; + background: inherit; + border-bottom: 1px dotted #ccc; + border-top: 2px solid #fff; + vertical-align: top; +} + +#log td:not(:last-child) { + max-width: 175px; + padding: 0 4px; + overflow: hidden; + white-space: nowrap; +} + +#log td[data-title]:hover { + font-size: 1px; + overflow: inherit; + position: relative; +} + +#log td:nth-child(3):hover:after { + content: attr(data-title); + display: block; + position: absolute; + border-radius: 4px; + box-shadow: 1px 1px 2px #ccc; + background: inherit; + border: 1px solid #ccc; + background: #efefef; + padding: 1px 1px 0 1px; + font-size: 10pt; + top: -2px; + left: 2px; + color: #000; +} + +#log td:last-child { + width: 100%; +} + +table#gameinfo, +table#modslist { + border: 1px solid #000000; + background: #ffffff; + border-radius: 5px; + border-spacing: 1px; + overflow: hidden; + cursor: default; + box-shadow: 1px 1px 1px 1px #dddddd; +} + +#modslist { + min-width: 400px; +} + +#gameinfo td:first-child { + padding-right: 5px; +} + +#gameinfo tr, +#modslist tr { + background: #eee +} + +#gameinfo tr:nth-child(even), +#modslist tr:nth-child(even) { + background: #fff +} + +#modslist tr { + cursor: pointer; +} + +span.notice { + font-weight: normal; + font-size: 11px; + position: relative; + top: -1px; + display: none; +} + +span.notice.btn { + cursor: pointer; + border: 1px solid #000; + border-radius: 5px; + position: relative; + top: -1px; + padding: 0 2px; + background: #eee; +} + +#output:not(.modfilter) span.notice.txt { + display: inline-block; +} + +#output.modfilter span.notice.btn { + display: inline-block; +} diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css new file mode 100644 index 00000000..d1fa49e0 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -0,0 +1,107 @@ +/* tags */ +html { + height: 100%; +} + +body { + height: 100%; + font-family: sans-serif; +} + +h1, h2, h3 { + font-weight: bold; + margin: 0.2em 0 0.1em 0; + padding-top: .5em; +} + +h1 { + font-size: 1.5em; + color: #888; + margin-bottom: 0; +} + +h2 { + font-size: 1.5em; + border-bottom: 1px solid #AAA; +} + +h3 { + font-size: 1.2em; + border-bottom: 1px solid #AAA; + width: 50%; +} + +a { + color: #006; +} + +/* content */ +#content-column { + position: absolute; + top: 1em; + left: 10em; +} + +#content { + min-height: 140px; + padding: 0 1em 1em 1em; + border-left: 1px solid #CCC; + background: #FFF; + font-size: 0.9em; +} + +#content p { + max-width: 55em; +} + +.section { + border: 1px solid #CCC; + padding: 0.5em; + margin-bottom: 1em; +} + +/* sidebar */ +#sidebar { + margin-top: 3em; + min-height: 75%; + width: 12em; + background: url("../images/sidebar-bg.gif") no-repeat top right; + color: #666; +} + +#sidebar h4 { + margin: 0 0 0.2em 0; + width: 10em; + border-bottom: 1px solid #CCC; + font-size: 0.8em; + font-weight: normal; +} + +#sidebar a { + color: #77B; + border: 0; +} + +#sidebar ul, #sidebar li { + margin: 0; + padding: 0; + list-style: none none; + font-size: 0.9em; + color: #888; +} + +#sidebar li { + margin-left: 1em; +} + +/* footer */ +#footer { + margin: 1em; + padding: 1em; + font-size: 0.6em; + color: gray; +} + +#footer a { + color: #669; +} diff --git a/src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gif b/src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gif new file mode 100644 index 00000000..48e9af5a Binary files /dev/null and b/src/SMAPI.Web/wwwroot/Content/images/sidebar-bg.gif differ diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js new file mode 100644 index 00000000..8e30ae12 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -0,0 +1,278 @@ +/* globals $ */ + +var smapi = smapi || {}; +smapi.logParser = function(sectionUrl, pasteID) { + /********* + ** Initialisation + *********/ + var stage, + flags = $("#modflags"), + output = $("#output"), + filters = 0, + memory = "", + versionInfo, + modInfo, + modMap, + modErrors, + logInfo, + templateBody = $("#template-body").text(), + templateModentry = $("#template-modentry").text(), + templateCss = $("#template-css").text(), + templateLogentry = $("#template-logentry").text(), + templateLognotice = $("#template-lognotice").text(), + regexInfo = /\[[\d\:]+ INFO SMAPI] SMAPI (.*?) with Stardew Valley (.*?) on (.*?)\n/g, + regexMods = /\[[^\]]+\] Loaded \d+ mods:(?:\n\[[^\]]+\] .+)+/g, + regexLog = /\[([\d\:]+) (TRACE|DEBUG|INFO|WARN|ALERT|ERROR) ? ([^\]]+)\] ?((?:\n|.)*?)(?=(?:\[\d\d:|$))/g, + regexMod = /\[(?:.*?)\] *(.*?) (\d+\.?(?:.*?))(?: by (.*?))? \|(?:.*?)$/gm, + regexDate = /\[\d{2}:\d{2}:\d{2} TRACE SMAPI\] Log started at (.*?) UTC/g, + regexPath = /\[\d{2}:\d{2}:\d{2} DEBUG SMAPI\] Mods go here: (.*?)(?:\n|$)/g; + + $("#tabs li:not(.notice)").on("click", function(evt) { + var t = $(evt.currentTarget); + t.toggleClass("active"); + $("#output").toggleClass(t.text().toLowerCase()); + }); + $("#upload-button").on("click", function() { + memory = $("#input").val() || ""; + $("#input").val(""); + $("#popup-upload").fadeIn(); + }); + $("#popup-upload").on({ + 'dragover dragenter': function(e) { + e.preventDefault(); + e.stopPropagation(); + }, + 'drop': function(e) { + $("#uploader").attr("data-text", "Reading..."); + $("#uploader").show(); + var dataTransfer = e.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.files.length) { + e.preventDefault(); + e.stopPropagation(); + var file = dataTransfer.files[0]; + var reader = new FileReader(); + reader.onload = $.proxy(function(file, $input, event) { + $input.val(event.target.result); + $("#uploader").fadeOut(); + $("#submit").click(); + }, this, file, $("#input")); + reader.readAsText(file); + } + } + }); + + $("#submit").on("click", function() { + $("#popup-upload").fadeOut(); + var paste = $("#input").val(); + if (paste) { + memory = ""; + $("#uploader").attr("data-text", "Saving..."); + $("#uploader").fadeIn(); + $ + .ajax({ + type: "POST", + url: sectionUrl + "/save", + data: JSON.stringify(paste), + contentType: "application/json" // sent to API + }) + .fail(function(xhr, textStatus) { + $("#uploader").fadeOut(); + $("#output").html('

Parsing failed!

Parsing of the log failed, details follow.
 

Stage: Upload

Error: ' + textStatus + ': ' + xhr.responseText + "
" + $("#input").val() + "
"); + }) + .then(function(data) { + $("#uploader").fadeOut(); + if (!data.success) + $("#output").html('

Parsing failed!

Parsing of the log failed, details follow.
 

Stage: Upload

Error: ' + data.error + "
" + $("#input").val() + "
"); + else + location.href = (sectionUrl.replace(/\/$/, "") + "/" + data.id); + }); + } else { + alert("Unable to parse log, the input is empty!"); + $("#uploader").fadeOut(); + } + }); + $("#cancel").on("click", function() { + $("#popup-upload").fadeOut(400, function() { + $("#input").val(memory); + memory = ""; + }); + }); + $("#closeraw").on("click", function() { + $("#popup-raw").fadeOut(400); + }); + if (pasteID) { + getData(pasteID); + } + else + $("#popup-upload").fadeIn(); + + + /********* + ** Helpers + *********/ + function modClicked(evt) { + var id = $(evt.currentTarget).attr("id").split("-")[1], + cls = "mod-" + id; + if (output.hasClass(cls)) + filters--; + else + filters++; + output.toggleClass(cls); + if (filters === 0) { + output.removeClass("modfilter"); + } else { + output.addClass("modfilter"); + } + } + + function removeFilter() { + for (var c = 0; c < modInfo.length; c++) { + output.removeClass("mod-" + c); + } + filters = 0; + output.removeClass("modfilter"); + } + + function selectAll() { + for (var c = 0; c < modInfo.length; c++) { + output.addClass("mod-" + c); + } + filters = modInfo.length; + output.addClass("modfilter"); + } + + function parseData() { + stage = "parseData.pre"; + var data = $("#input").val(); + if (!data) { + stage = "parseData.checkNullData"; + throw new Error("Field `data` is null"); + + } + var dataInfo = regexInfo.exec(data) || regexInfo.exec(data) || regexInfo.exec(data), + dataMods = regexMods.exec(data) || regexMods.exec(data) || regexMods.exec(data), + dataDate = regexDate.exec(data) || regexDate.exec(data) || regexDate.exec(data), + dataPath = regexPath.exec(data) || regexPath.exec(data) || regexPath.exec(data), + match; + stage = "parseData.doNullCheck"; + if (!dataInfo) + throw new Error("Field `dataInfo` is null"); + if (!dataMods) + throw new Error("Field `dataMods` is null"); + if (!dataPath) + throw new Error("Field `dataPath` is null"); + dataMods = dataMods[0]; + stage = "parseData.setupDefaults"; + modMap = { + "SMAPI": 0 + }; + modErrors = { + "SMAPI": 0, + "Console.Out": 0 + }; + logInfo = []; + modInfo = [ + ["SMAPI", dataInfo[1], "Zoryn, CLxS & Pathoschild"] + ]; + stage = "parseData.parseInfo"; + var date = dataDate ? new Date(dataDate[1] + "Z") : null; + versionInfo = [dataInfo[1], dataInfo[2], dataInfo[3], date ? date.getFullYear() + "-" + ("0" + date.getMonth().toString()).substr(-2) + "-" + ("0" + date.getDay().toString()).substr(-2) + " at " + date.getHours() + ":" + date.getMinutes() + ":" + date.getSeconds() + " " + date.toLocaleTimeString("en-us", { timeZoneName: "short" }).split(" ")[2] : "No timestamp found", dataPath[1]]; + stage = "parseData.parseMods"; + while ((match = regexMod.exec(dataMods))) { + modErrors[match[1]] = 0; + modMap[match[1]] = modInfo.length; + modInfo.push([match[1], match[2], match[3] ? ("by " + match[3]) : "Unknown author"]); + } + stage = "parseData.parseLog"; + while ((match = regexLog.exec(data))) { + if (match[2] === "ERROR") + modErrors[match[3]]++; + logInfo.push([match[1], match[2], match[3], match[4]]); + } + stage = "parseData.post"; + modMap["Console.Out"] = modInfo.length; + modInfo.push(["Console.Out", "", ""]); + } + + function renderData() { + stage = "renderData.pre"; + output.html(prepare(templateBody, versionInfo)); + var modslist = $("#modslist"), log = $("#log"), modCache = [], y = 0; + for (; y < modInfo.length; y++) { + var errors = modErrors[modInfo[y][0]], + err, cls = "color-red"; + if (errors === 0) { + err = "No Errors"; + cls = "color-green"; + } else if (errors === 1) + err = "1 Error"; + else + err = errors + " Errors"; + modCache.push(prepare(templateModentry, [y, modInfo[y][0], modInfo[y][1], modInfo[y][2], cls, err])); + } + modslist.append(modCache.join("")); + for (var z = 0; z < modInfo.length; z++) + $("#modlink-" + z).on("click", modClicked); + var flagCache = []; + for (var c = 0; c < modInfo.length; c++) + flagCache.push(prepare(templateCss, [c])); + flags.html(flagCache.join("")); + var logCache = [], dupeCount = 0, dupeMemory = "|||"; + for (var x = 0; x < logInfo.length; x++) { + var dm = logInfo[x][1] + "|" + logInfo[x][2] + "|" + logInfo[x][3]; + if (dupeMemory !== dm) { + if (dupeCount > 0) + logCache.push(prepare(templateLognotice, [logInfo[x - 1][1].toLowerCase(), modMap[logInfo[x - 1][2]], dupeCount])); + dupeCount = 0; + dupeMemory = dm; + logCache.push(prepare(templateLogentry, [logInfo[x][1].toLowerCase(), modMap[logInfo[x][2]], logInfo[x][0], logInfo[x][1], logInfo[x][2], logInfo[x][3].split(" ").join("  ").replace(//g, ">").replace(/\n/g, "
")])); + } + else + dupeCount++; + } + log.append(logCache.join("")); + $("#modlink-r").on("click", removeFilter); + $("#modlink-a").on("click", selectAll); + } + + function prepare(str, arr) { + var regex = /\{(\d)\}/g, + match; + while ((match = regex.exec(str))) + str = str.replace(match[0], arr[match[1]]); + return str; + } + function loadData() { + try { + stage = "loadData.Pre"; + var start = performance.now(); + parseData(); + renderData(); + var end = performance.now(); + $(".always").prepend("
Log processed in: " + (Math.round((end - start) * 100) / 100) + ' ms (View raw)

'); + $("#viewraw").on("click", function() { + $("#dataraw").val($("#input").val()); + $("#popup-raw").fadeIn(); + }); + stage = "loadData.Post"; + } + catch (err) { + $("#output").html('

Parsing failed!

Parsing of the log failed, details follow.
 

Stage: ' + stage + "

" + err + '
'); + $("#rawlog").text($("#input").val()); + } + } + function getData(pasteID) { + $("#uploader").attr("data-text", "Loading..."); + $("#uploader").fadeIn(); + $.get(sectionUrl + "/fetch/" + pasteID, function(data) { + if (data.success) { + $("#input").val(data.content); + loadData(); + } else { + $("#output").html('

Fetching the log failed!

' + data.error + '

'); + $("#rawlog").text($("#input").val()); + } + $("#uploader").fadeOut(); + }); + } +}; diff --git a/src/SMAPI.Web/wwwroot/favicon.ico b/src/SMAPI.Web/wwwroot/favicon.ico new file mode 100644 index 00000000..587a6e74 Binary files /dev/null and b/src/SMAPI.Web/wwwroot/favicon.ico differ diff --git a/src/SMAPI.sln b/src/SMAPI.sln index 89a8d45c..b42e39ce 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -1,9 +1,9 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.16 +VisualStudioVersion = 15.0.27004.2002 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainerMod", "TrainerMod\TrainerMod.csproj", "{28480467-1A48-46A7-99F8-236D95225359}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\StardewModdingAPI.Mods.ConsoleCommands.csproj", "{28480467-1A48-46A7-99F8-236D95225359}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI", "SMAPI\StardewModdingAPI.csproj", "{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}" EndProject @@ -37,6 +37,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{EB35A917-6 ..\docs\mod-build-config.md = ..\docs\mod-build-config.md ..\docs\README.md = ..\docs\README.md ..\docs\release-notes.md = ..\docs\release-notes.md + ..\docs\technical-docs.md = ..\docs\technical-docs.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5-5BAB-4650-A200-E5EA9A633046}" @@ -51,6 +52,7 @@ EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Common\StardewModdingAPI.Common.projitems*{2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc}*SharedItemsImports = 13 + SMAPI.Common\StardewModdingAPI.Common.projitems*{443ddf81-6aaf-420a-a610-3459f37e5575}*SharedItemsImports = 4 SMAPI.Common\StardewModdingAPI.Common.projitems*{ea4f1e80-743f-4a1d-9757-ae66904a196a}*SharedItemsImports = 4 SMAPI.Common\StardewModdingAPI.Common.projitems*{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}*SharedItemsImports = 4 EndGlobalSection diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 7721fd5e..a2dbdd98 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -29,7 +29,7 @@ namespace StardewModdingAPI ** Public ****/ /// SMAPI's current semantic version. - public static ISemanticVersion ApiVersion { get; } = new SemanticVersion(2, 0, 0); + public static ISemanticVersion ApiVersion { get; } = new SemanticVersion(2, 1, 0); /// The minimum supported version of Stardew Valley. public static ISemanticVersion MinimumGameVersion { get; } = new SemanticVersion("1.2.30"); diff --git a/src/SMAPI/Events/EventArgsInput.cs b/src/SMAPI/Events/EventArgsInput.cs index 66cb19f2..ff904675 100644 --- a/src/SMAPI/Events/EventArgsInput.cs +++ b/src/SMAPI/Events/EventArgsInput.cs @@ -2,7 +2,6 @@ using System; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; -using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Events @@ -20,7 +19,14 @@ namespace StardewModdingAPI.Events public ICursorPosition Cursor { get; set; } /// Whether the input is considered a 'click' by the game for enabling action. - public bool IsClick { get; } + [Obsolete("Use " + nameof(EventArgsInput.IsActionButton) + " or " + nameof(EventArgsInput.IsUseToolButton) + " instead")] // deprecated in SMAPI 2.1 + public bool IsClick => this.IsActionButton; + + /// Whether the input should trigger actions on the affected tile. + public bool IsActionButton { get; } + + /// Whether the input should use tools on the affected tile. + public bool IsUseToolButton { get; } /********* @@ -29,12 +35,14 @@ namespace StardewModdingAPI.Events /// Construct an instance. /// The button on the controller, keyboard, or mouse. /// The cursor position. - /// Whether the input is considered a 'click' by the game for enabling action. - public EventArgsInput(SButton button, ICursorPosition cursor, bool isClick) + /// Whether the input should trigger actions on the affected tile. + /// Whether the input should use tools on the affected tile. + public EventArgsInput(SButton button, ICursorPosition cursor, bool isActionButton, bool isUseToolButton) { this.Button = button; this.Cursor = cursor; - this.IsClick = isClick; + this.IsActionButton = isActionButton; + this.IsUseToolButton = isUseToolButton; } /// Prevent the game from handling the vurrent button press. This doesn't prevent other mods from receiving the event. @@ -49,7 +57,7 @@ namespace StardewModdingAPI.Events { // keyboard if (this.Button.TryGetKeyboard(out Keys key)) - Game1.oldKBState = new KeyboardState(Game1.oldKBState.GetPressedKeys().Except(new[] { key }).ToArray()); + Game1.oldKBState = new KeyboardState(Game1.oldKBState.GetPressedKeys().Union(new[] { key }).ToArray()); // controller else if (this.Button.TryGetController(out Buttons controllerButton)) diff --git a/src/SMAPI/Events/InputEvents.cs b/src/SMAPI/Events/InputEvents.cs index c31eb698..985aed99 100644 --- a/src/SMAPI/Events/InputEvents.cs +++ b/src/SMAPI/Events/InputEvents.cs @@ -1,6 +1,5 @@ using System; using StardewModdingAPI.Framework; -using StardewModdingAPI.Utilities; namespace StardewModdingAPI.Events { @@ -24,20 +23,22 @@ namespace StardewModdingAPI.Events /// Encapsulates monitoring and logging. /// The button on the controller, keyboard, or mouse. /// The cursor position. - /// Whether the input is considered a 'click' by the game for enabling action. - internal static void InvokeButtonPressed(IMonitor monitor, SButton button, ICursorPosition cursor, bool isClick) + /// Whether the input should trigger actions on the affected tile. + /// Whether the input should use tools on the affected tile. + internal static void InvokeButtonPressed(IMonitor monitor, SButton button, ICursorPosition cursor, bool isActionButton, bool isUseToolButton) { - monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonPressed)}", InputEvents.ButtonPressed?.GetInvocationList(), null, new EventArgsInput(button, cursor, isClick)); + monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonPressed)}", InputEvents.ButtonPressed?.GetInvocationList(), null, new EventArgsInput(button, cursor, isActionButton, isUseToolButton)); } /// Raise a event. /// Encapsulates monitoring and logging. /// The button on the controller, keyboard, or mouse. /// The cursor position. - /// Whether the input is considered a 'click' by the game for enabling action. - internal static void InvokeButtonReleased(IMonitor monitor, SButton button, ICursorPosition cursor, bool isClick) + /// Whether the input should trigger actions on the affected tile. + /// Whether the input should use tools on the affected tile. + internal static void InvokeButtonReleased(IMonitor monitor, SButton button, ICursorPosition cursor, bool isActionButton, bool isUseToolButton) { - monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonReleased)}", InputEvents.ButtonReleased?.GetInvocationList(), null, new EventArgsInput(button, cursor, isClick)); + monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonReleased)}", InputEvents.ButtonReleased?.GetInvocationList(), null, new EventArgsInput(button, cursor, isActionButton, isUseToolButton)); } } } diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs new file mode 100644 index 00000000..10c41d08 --- /dev/null +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Framework.Content +{ + /// A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localisation, loading content, etc. It assumes all keys passed in are already normalised. + internal class ContentCache + { + /********* + ** Properties + *********/ + /// The underlying asset cache. + private readonly IDictionary Cache; + + /// The possible directory separator characters in an asset key. + private readonly char[] PossiblePathSeparators; + + /// The preferred directory separator chaeacter in an asset key. + private readonly string PreferredPathSeparator; + + /// Applies platform-specific asset key normalisation so it's consistent with the underlying cache. + private readonly Func NormaliseAssetNameForPlatform; + + + /********* + ** Accessors + *********/ + /// Get or set the value of a raw cache entry. + /// The cache key. + public object this[string key] + { + get => this.Cache[key]; + set => this.Cache[key] = value; + } + + /// The current cache keys. + public IEnumerable Keys => this.Cache.Keys; + + + /********* + ** Public methods + *********/ + /**** + ** Constructor + ****/ + /// Construct an instance. + /// The underlying content manager whose cache to manage. + /// Simplifies access to private game code. + /// The possible directory separator characters in an asset key. + /// The preferred directory separator chaeacter in an asset key. + public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator) + { + // init + this.Cache = reflection.GetPrivateField>(contentManager, "loadedAssets").GetValue(); + this.PossiblePathSeparators = possiblePathSeparators; + this.PreferredPathSeparator = preferredPathSeparator; + + // get key normalisation logic + if (Constants.TargetPlatform == Platform.Windows) + { + IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath"); + this.NormaliseAssetNameForPlatform = path => method.Invoke(path); + } + else + this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load logic + } + + /**** + ** Fetch + ****/ + /// Get whether the cache contains a given key. + /// The cache key. + public bool ContainsKey(string key) + { + return this.Cache.ContainsKey(key); + } + + + /**** + ** Normalise + ****/ + /// Normalise path separators in a file path. For asset keys, see instead. + /// The file path to normalise. + [Pure] + public string NormalisePathSeparators(string path) + { + string[] parts = path.Split(this.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + string normalised = string.Join(this.PreferredPathSeparator, parts); + if (path.StartsWith(this.PreferredPathSeparator)) + normalised = this.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// Normalise a cache key so it's consistent with the underlying cache. + /// The asset key. + [Pure] + public string NormaliseKey(string key) + { + key = this.NormalisePathSeparators(key); + return key.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase) + ? key.Substring(0, key.Length - 4) + : this.NormaliseAssetNameForPlatform(key); + } + + /**** + ** Remove + ****/ + /// Remove an asset with the given key. + /// The cache key. + /// Whether to dispose the entry value, if applicable. + /// Returns the removed key (if any). + public bool Remove(string key, bool dispose) + { + // get entry + if (!this.Cache.TryGetValue(key, out object value)) + return false; + + // dispose & remove entry + if (dispose && value is IDisposable disposable) + disposable.Dispose(); + + return this.Cache.Remove(key); + } + + /// Purge matched assets from the cache. + /// Matches the asset keys to invalidate. + /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. + /// Returns the removed keys (if any). + public IEnumerable Remove(Func predicate, bool dispose = false) + { + List removed = new List(); + foreach (string key in this.Cache.Keys.ToArray()) + { + Type type = this.Cache[key].GetType(); + if (predicate(key, type)) + { + this.Remove(key, dispose); + removed.Add(key); + } + } + return removed; + } + } +} diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index 48159f61..1884afe9 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace StardewModdingAPI.Framework @@ -22,6 +22,7 @@ namespace StardewModdingAPI.Framework ["1.06"] = "1.0.6", ["1.07"] = "1.0.7", ["1.07a"] = "1.0.8-prerelease1", + ["1.08"] = "1.0.8", ["1.11"] = "1.1.1" }; diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 4f5bd2f0..be9594ee 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -4,7 +4,6 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; @@ -74,12 +73,12 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ContentManager = contentManager; this.ModFolderPath = modFolderPath; this.ModName = modName; - this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); + this.ModFolderPathFromContent = this.ContentManager.GetRelativePath(modFolderPath); this.Monitor = monitor; } /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. + /// The expected data type. The main supported types are , , and dictionaries; other types may be supported by the game's content pipeline. /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. /// Where to search for a matching content asset. /// The is empty or contains invalid characters. @@ -88,9 +87,9 @@ namespace StardewModdingAPI.Framework.ModHelpers { SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); - this.AssertValidAssetKeyFormat(key); try { + this.AssertValidAssetKeyFormat(key); switch (source) { case ContentSource.GameContent: @@ -103,60 +102,32 @@ namespace StardewModdingAPI.Framework.ModHelpers throw GetContentError($"there's no matching file at path '{file.FullName}'."); // get asset path - string assetPath = this.GetModAssetPath(key, file.FullName); + string assetName = this.GetModAssetPath(key, file.FullName); // try cache - if (this.ContentManager.IsLoaded(assetPath)) - return this.ContentManager.Load(assetPath); + if (this.ContentManager.IsLoaded(assetName)) + return this.ContentManager.Load(assetName); - // load content - switch (file.Extension.ToLower()) + // fix map tilesheets + if (file.Extension.ToLower() == ".tbin") { - // XNB file - case ".xnb": - { - T asset = this.ContentManager.Load(assetPath); - if (asset is Map) - this.FixLocalMapTilesheets(asset as Map, key); - return asset; - } + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - // unpacked map - case ".tbin": - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.FixCustomTilesheetPaths(map, key); - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - this.FixLocalMapTilesheets(map, key); - - // inject map - this.ContentManager.Inject(assetPath, map); - return (T)(object)map; - } - - // unpacked image - case ".png": - // validate - if (typeof(T) != typeof(Texture2D)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // fetch & cache - using (FileStream stream = File.OpenRead(file.FullName)) - { - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); - this.ContentManager.Inject(assetPath, texture); - return (T)(object)texture; - } - - default: - throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); + // inject map + this.ContentManager.Inject(assetName, map, this.ContentManager); + return (T)(object)map; } + // load through content manager + return this.ContentManager.Load(assetName); + default: throw GetContentError($"unknown content source '{source}'."); } @@ -193,9 +164,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Returns whether the given asset key was cached. 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)); + this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace); + return this.ContentManager.InvalidateCache(asset => asset.AssetNameEquals(actualKey)); } /// Remove all assets of the given type from the cache so they're reloaded on the next request. This can be a very expensive operation and should only be used in very specific cases. This will reload core game assets if needed, but references to the former assets will still show the previous content. @@ -207,28 +178,50 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentManager.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type)); } + /// Remove matching assets from the content cache so they're 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. + /// A predicate matching the assets to invalidate. + /// Returns whether any cache entries were invalidated. + public bool InvalidateCache(Func predicate) + { + this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace); + return this.ContentManager.InvalidateCache(predicate); + } + /********* ** Private methods *********/ - /// Fix the tilesheets for a map loaded from the mod folder. + /// Assert that the given key has a valid format. + /// The asset key to check. + /// The asset key is empty or contains invalid characters. + [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] + private void AssertValidAssetKeyFormat(string key) + { + this.ContentManager.AssertValidAssetKeyFormat(key); + if (Path.IsPathRooted(key)) + throw new ArgumentException("The asset key must not be an absolute path."); + } + + /// Fix custom map tilesheet paths so they can be found by the content manager. /// The map whose tilesheets to fix. /// The map asset key within the mod folder. - /// The map tilesheets could not be loaded. + /// A map tilesheet couldn't be resolved. /// - /// The game's logic for tilesheets in 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 Content folder. + /// The game's logic for tilesheets in 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 Content folder. /// * Else it's loaded from Content\Maps 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 - /// Content\Maps, then Content. If the image source filename contains a seasonal prefix, we try - /// for a seasonal variation and then an exact match. + /// Content\Maps, then Content. If the image source filename contains a seasonal prefix, 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. /// - private void FixLocalMapTilesheets(Map map, string mapKey) + private void FixCustomTilesheetPaths(Map map, string mapKey) { - // check map info + // get map info if (!map.TileSheets.Any()) return; mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators @@ -239,7 +232,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { string imageSource = tilesheet.ImageSource; - // validate + // validate tilesheet path if (Path.IsPathRooted(imageSource) || imageSource.Split(SContentManager.PossiblePathSeparators).Contains("..")) throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); @@ -264,8 +257,8 @@ namespace StardewModdingAPI.Framework.ModHelpers try { string key = - this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource) - ?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource); + this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) + ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); if (key != null) { tilesheet.ImageSource = key; @@ -282,33 +275,22 @@ namespace StardewModdingAPI.Framework.ModHelpers } } - /// Load a tilesheet image source if the file exists. - /// The folder path containing the map, relative to the mod folder. + /// Get the actual asset name for a tilesheet. + /// The folder path containing the map, relative to the mod folder. /// The tilesheet image source to load. - /// Returns the loaded asset key (if it was loaded successfully). - /// See remarks on . - private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource) + /// Returns the asset name. + /// See remarks on . + private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) { if (imageSource == null) return null; // check relative to map file { - string localKey = Path.Combine(relativeMapFolder, imageSource); + string localKey = Path.Combine(modRelativeMapFolder, imageSource); FileInfo localFile = this.GetModFile(localKey); if (localFile.Exists) - { - try - { - this.Load(localKey); - } - catch (Exception ex) - { - throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex); - } - return this.GetActualAssetKey(localKey); - } } // check relative to content folder @@ -327,7 +309,7 @@ namespace StardewModdingAPI.Framework.ModHelpers catch { // ignore file-not-found errors - // TODO: while it's useful to suppress a asset-not-found error here to avoid + // TODO: while it's useful to suppress an 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 @@ -343,18 +325,6 @@ namespace StardewModdingAPI.Framework.ModHelpers return null; } - /// Assert that the given key has a valid format. - /// The asset key to check. - /// The asset key is empty or contains invalid characters. - [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")] - private void AssertValidAssetKeyFormat(string key) - { - if (string.IsNullOrWhiteSpace(key)) - throw new ArgumentException("The asset key or local path is empty."); - if (key.Intersect(Path.GetInvalidPathChars()).Any()) - throw new ArgumentException("The asset key or local path contains invalid characters."); - } - /// Get a file from the mod folder. /// The asset path relative to the mod folder. private FileInfo GetModFile(string path) @@ -400,81 +370,5 @@ namespace StardewModdingAPI.Framework.ModHelpers return absolutePath; #endif } - - /// Get a directory path relative to a given root. - /// The root path from which the path should be relative. - /// The target file path. - private string GetRelativePath(string rootPath, string targetPath) - { - // convert to URIs - Uri from = new Uri(rootPath + "/"); - Uri to = new Uri(targetPath + "/"); - if (from.Scheme != to.Scheme) - throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'."); - - // get relative path - return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) - .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform - } - - /// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing. - /// The texture to premultiply. - /// Returns a premultiplied texture. - /// Based on code by Layoric. - private Texture2D PremultiplyTransparency(Texture2D texture) - { - // validate - if (Context.IsInDrawLoop) - throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); - - // process texture - SpriteBatch spriteBatch = Game1.spriteBatch; - GraphicsDevice gpu = Game1.graphics.GraphicsDevice; - using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) - { - // create blank render target to premultiply - gpu.SetRenderTarget(renderTarget); - gpu.Clear(Color.Black); - - // multiply each color by the source alpha, and write just the color values into the final texture - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorDestinationBlend = Blend.Zero, - ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, - AlphaDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.SourceAlpha, - ColorSourceBlend = Blend.SourceAlpha - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // copy the alpha values from the source texture into the final one without multiplying them - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorWriteChannels = ColorWriteChannels.Alpha, - AlphaDestinationBlend = Blend.Zero, - ColorDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.One, - ColorSourceBlend = Blend.One - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // release GPU - gpu.SetRenderTarget(null); - - // extract premultiplied data - Color[] data = new Color[texture.Width * texture.Height]; - renderTarget.GetData(data); - - // unset texture from GPU to regain control - gpu.Textures[0] = null; - - // update texture with premultiplied data - texture.SetData(data); - } - - return texture; - } } } diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 8d435416..8788b142 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers @@ -42,8 +43,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Returns the field wrapper, or null if the field doesn't exist and is false. public IPrivateField GetPrivateField(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateField(obj, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateField(obj, name, required) + ); } /// Get a private static field. @@ -53,8 +55,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateField GetPrivateField(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateField(type, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateField(type, name, required) + ); } /**** @@ -67,8 +70,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private property is not found. public IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateProperty(obj, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateProperty(obj, name, required) + ); } /// Get a private static property. @@ -78,8 +82,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private property is not found. public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateProperty(type, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateProperty(type, name, required) + ); } /**** @@ -98,7 +103,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// public TValue GetPrivateValue(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); IPrivateField field = this.GetPrivateField(obj, name, required); return field != null ? field.GetValue() @@ -117,7 +121,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// public TValue GetPrivateValue(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); IPrivateField field = this.GetPrivateField(type, name, required); return field != null ? field.GetValue() @@ -133,8 +136,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateMethod(obj, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(obj, name, required) + ); } /// Get a private static method. @@ -143,8 +147,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateMethod(type, name, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(type, name, required) + ); } /**** @@ -157,8 +162,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) { - this.AssertAccessAllowed(obj); - return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required) + ); } /// Get a private static method. @@ -168,33 +174,60 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Whether to throw an exception if the private field is not found. public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) { - this.AssertAccessAllowed(type); - return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required); + return this.AssertAccessAllowed( + this.Reflector.GetPrivateMethod(type, name, argumentTypes, required) + ); } /********* ** Private methods *********/ - /// Assert that mods can use the reflection helper to access the given type. - /// The type being accessed. - private void AssertAccessAllowed(Type type) + /// Assert that mods can use the reflection helper to access the given member. + /// The field value type. + /// The field being accessed. + /// Returns the same field instance for convenience. + private IPrivateField AssertAccessAllowed(IPrivateField field) { - // 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."); - } + this.AssertAccessAllowed(field?.FieldInfo); + return field; } - /// Assert that mods can use the reflection helper to access the given type. - /// The object being accessed. - private void AssertAccessAllowed(object obj) + /// Assert that mods can use the reflection helper to access the given member. + /// The property value type. + /// The property being accessed. + /// Returns the same property instance for convenience. + private IPrivateProperty AssertAccessAllowed(IPrivateProperty property) { - if (obj != null) - this.AssertAccessAllowed(obj.GetType()); + this.AssertAccessAllowed(property?.PropertyInfo); + return property; + } + + /// Assert that mods can use the reflection helper to access the given member. + /// The method being accessed. + /// Returns the same method instance for convenience. + private IPrivateMethod AssertAccessAllowed(IPrivateMethod method) + { + this.AssertAccessAllowed(method?.MethodInfo); + return method; + } + + /// Assert that mods can use the reflection helper to access the given member. + /// The member being accessed. + private void AssertAccessAllowed(MemberInfo member) + { + if (member == null) + return; + + // get type which defines the member + Type declaringType = member.DeclaringType; + if (declaringType == null) + throw new InvalidOperationException($"Can't validate access to {member.MemberType} {member.Name} because it has no declaring type."); // should never happen + + // validate access + string rootNamespace = typeof(Program).Namespace; + if (declaringType.Namespace == rootNamespace || declaringType.Namespace?.StartsWith(rootNamespace + ".") == true) + 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. (Detected access to {declaringType.FullName}.{member.Name}.)"); } } } diff --git a/src/SMAPI/Framework/Reflection/PrivateProperty.cs b/src/SMAPI/Framework/Reflection/PrivateProperty.cs index 08204b7e..be346d71 100644 --- a/src/SMAPI/Framework/Reflection/PrivateProperty.cs +++ b/src/SMAPI/Framework/Reflection/PrivateProperty.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Reflection; namespace StardewModdingAPI.Framework.Reflection @@ -10,14 +10,14 @@ namespace StardewModdingAPI.Framework.Reflection /********* ** Properties *********/ - /// The type that has the field. - private readonly Type ParentType; - - /// The object that has the instance field (if applicable). - private readonly object Parent; - /// The display name shown in error messages. - private string DisplayName => $"{this.ParentType.FullName}::{this.PropertyInfo.Name}"; + private readonly string DisplayName; + + /// The underlying property getter. + private readonly Func GetterDelegate; + + /// The underlying property setter. + private readonly Action SetterDelegate; /********* @@ -39,20 +39,24 @@ namespace StardewModdingAPI.Framework.Reflection /// The is null for a non-static field, or not null for a static field. public PrivateProperty(Type parentType, object obj, PropertyInfo property, bool isStatic) { - // validate + // validate input if (parentType == null) throw new ArgumentNullException(nameof(parentType)); if (property == null) throw new ArgumentNullException(nameof(property)); + + // validate static if (isStatic && obj != null) throw new ArgumentException("A static property cannot have an object instance."); if (!isStatic && obj == null) throw new ArgumentException("A non-static property must have an object instance."); - // save - this.ParentType = parentType; - this.Parent = obj; + + this.DisplayName = $"{parentType.FullName}::{property.Name}"; this.PropertyInfo = property; + + this.GetterDelegate = (Func)Delegate.CreateDelegate(typeof(Func), obj, this.PropertyInfo.GetMethod); + this.SetterDelegate = (Action)Delegate.CreateDelegate(typeof(Action), obj, this.PropertyInfo.SetMethod); } /// Get the property value. @@ -60,7 +64,7 @@ namespace StardewModdingAPI.Framework.Reflection { try { - return (TValue)this.PropertyInfo.GetValue(this.Parent); + return this.GetterDelegate(); } catch (InvalidCastException) { @@ -78,7 +82,7 @@ namespace StardewModdingAPI.Framework.Reflection { try { - this.PropertyInfo.SetValue(this.Parent, value); + this.SetterDelegate(value); } catch (InvalidCastException) { diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs index 5c2d90fa..23a48505 100644 --- a/src/SMAPI/Framework/Reflection/Reflector.cs +++ b/src/SMAPI/Framework/Reflection/Reflector.cs @@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a private instance field from a null object."); // get field from hierarchy - IPrivateField field = this.GetFieldFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + IPrivateField field = this.GetFieldFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && field == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance field."); return field; @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.Reflection public IPrivateField GetPrivateField(Type type, string name, bool required = true) { // get field from hierarchy - IPrivateField field = this.GetFieldFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + IPrivateField field = this.GetFieldFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public); if (required && field == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static field."); return field; @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a private instance property from a null object."); // get property from hierarchy - IPrivateProperty property = this.GetPropertyFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + IPrivateProperty property = this.GetPropertyFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && property == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance property."); return property; @@ -87,7 +87,7 @@ namespace StardewModdingAPI.Framework.Reflection public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) { // get field from hierarchy - IPrivateProperty property = this.GetPropertyFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + IPrivateProperty property = this.GetPropertyFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); if (required && property == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static property."); return property; @@ -107,7 +107,7 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); // get method from hierarchy - IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && method == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method."); return method; @@ -120,7 +120,7 @@ namespace StardewModdingAPI.Framework.Reflection public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) { // get method from hierarchy - IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); if (required && method == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method."); return method; @@ -141,7 +141,7 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); // get method from hierarchy - PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic, argumentTypes); + PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, argumentTypes); if (required && method == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method with that signature."); return method; @@ -155,7 +155,7 @@ namespace StardewModdingAPI.Framework.Reflection public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) { // get field from hierarchy - PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static, argumentTypes); + PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, argumentTypes); if (required && method == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method with that signature."); return method; diff --git a/src/SMAPI/Framework/SContentManager.cs b/src/SMAPI/Framework/SContentManager.cs index db202567..a755a6df 100644 --- a/src/SMAPI/Framework/SContentManager.cs +++ b/src/SMAPI/Framework/SContentManager.cs @@ -1,13 +1,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; -using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Metadata; @@ -15,7 +19,17 @@ using StardewValley; namespace StardewModdingAPI.Framework { - /// SMAPI's implementation of the game's content manager which lets it raise content events. + /// A thread-safe content manager which intercepts assets being loaded to let SMAPI mods inject or edit them. + /// + /// This is the centralised content manager which manages all game assets. The game and mods don't use this class + /// directly; instead they use one of several instances, which proxy requests to + /// this class. That ensures that when the game disposes one content manager, the others can continue unaffected. + /// That notably requires this class to be thread-safe, since the content managers can be disposed asynchronously. + /// + /// Note that assets in the cache have two identifiers: the asset name (like "bundles") and key (like "bundles.pt-BR"). + /// For English and non-translatable assets, these have the same value. The underlying cache only knows about asset + /// keys, and the game and mods only know about asset names. The content manager handles resolving them. + /// internal class SContentManager : LocalizedContentManager { /********* @@ -27,11 +41,8 @@ namespace StardewModdingAPI.Framework /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; - /// The underlying content manager's asset cache. - private readonly IDictionary Cache; - - /// Applies platform-specific asset key normalisation so it's consistent with the underlying cache. - private readonly Func NormaliseAssetNameForPlatform; + /// The underlying asset cache. + private readonly ContentCache Cache; /// The private method which generates the locale portion of an asset name. private readonly IPrivateMethod GetKeyLocale; @@ -46,10 +57,13 @@ namespace StardewModdingAPI.Framework private readonly ContextHash AssetsBeingLoaded = new ContextHash(); /// A lookup of the content managers which loaded each asset. - private readonly IDictionary> AssetLoaders = new Dictionary>(); + private readonly IDictionary> ContentManagersByAssetKey = new Dictionary>(); - /// An object locked to prevent concurrent changes to the underlying assets. - private readonly object Lock = new object(); + /// The path prefix for assets in mod folders. + private readonly string ModContentPrefix; + + /// A lock used to prevents concurrent changes to the cache while data is being read. + private readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); /********* @@ -71,121 +85,176 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ + /**** + ** Constructor + ****/ /// Construct an instance. /// The service provider to use to locate services. /// The root directory to search for content. /// The current culture for which to localise content. /// The current language code for which to localise content. /// Encapsulates monitoring and logging. - public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor) + /// Simplifies access to private code. + public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor, Reflector reflection) : base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride) { - // validate - if (monitor == null) - throw new ArgumentNullException(nameof(monitor)); - - // initialise - var reflection = new Reflector(); - this.Monitor = monitor; - - // get underlying fields for interception - this.Cache = reflection.GetPrivateField>(this, "loadedAssets").GetValue(); + // init + this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); + this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator); this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); - - // get asset key normalisation logic - if (Constants.TargetPlatform == Platform.Windows) - { - IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath"); - this.NormaliseAssetNameForPlatform = path => method.Invoke(path); - } - else - this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load logic + this.ModContentPrefix = this.GetRelativePath(Constants.ModPath); // get asset data this.CoreAssets = new CoreAssets(this.NormaliseAssetName); this.KeyLocales = this.GetKeyLocales(reflection); } + /**** + ** Asset key/name handling + ****/ /// Normalise path separators in a file path. For asset keys, see instead. /// The file path to normalise. + [Pure] public string NormalisePathSeparators(string path) { - string[] parts = path.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); - string normalised = string.Join(SContentManager.PreferredPathSeparator, parts); - if (path.StartsWith(SContentManager.PreferredPathSeparator)) - normalised = SContentManager.PreferredPathSeparator + normalised; // keep root slash - return normalised; + return this.Cache.NormalisePathSeparators(path); } /// Normalise an asset name so it's consistent with the underlying cache. /// The asset key. + [Pure] public string NormaliseAssetName(string assetName) { - assetName = this.NormalisePathSeparators(assetName); - if (assetName.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase)) - return assetName.Substring(0, assetName.Length - 4); - return this.NormaliseAssetNameForPlatform(assetName); + return this.Cache.NormaliseKey(assetName); + } + + /// Assert that the given key has a valid format. + /// The asset key to check. + /// The asset key is empty or contains invalid characters. + [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] + public void AssertValidAssetKeyFormat(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("The asset key or local path is empty."); + if (key.Intersect(Path.GetInvalidPathChars()).Any()) + throw new ArgumentException("The asset key or local path contains invalid characters."); + } + + /// Get a directory path relative to the content root. + /// The target file path. + public string GetRelativePath(string targetPath) + { + // convert to URIs + Uri from = new Uri(this.FullRootDirectory + "/"); + Uri to = new Uri(targetPath + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{this.FullRootDirectory}'."); + + // get relative path + return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) + .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform + } + + /**** + ** Content loading + ****/ + /// Get the current content locale. + public string GetLocale() + { + return this.GetKeyLocale.Invoke(); } /// Get whether the content manager has already loaded and cached the given asset. /// The asset path relative to the loader root directory, not including the .xnb extension. public bool IsLoaded(string assetName) { - lock (this.Lock) - { - assetName = this.NormaliseAssetName(assetName); - return this.IsNormalisedKeyLoaded(assetName); - } + assetName = this.Cache.NormaliseKey(assetName); + return this.WithReadLock(() => this.IsNormalisedKeyLoaded(assetName)); } - /// Load an asset that has been processed by the content pipeline. - /// The type of asset to load. - /// The asset path relative to the loader root directory, not including the .xnb extension. + /// Get the cached asset keys. + public IEnumerable GetAssetKeys() + { + return this.WithReadLock(() => + this.Cache.Keys + .Select(this.GetAssetName) + .Distinct() + ); + } + + /// Load an asset through the content pipeline. When loading a .png file, this must be called outside the game's draw loop. + /// The expected asset type. + /// The asset path relative to the content directory. public override T Load(string assetName) { return this.LoadFor(assetName, this); } - /// Load an asset that has been processed by the content pipeline. - /// The type of asset to load. - /// The asset path relative to the loader root directory, not including the .xnb extension. + /// Load an asset through the content pipeline. When loading a .png file, this must be called outside the game's draw loop. + /// The expected asset type. + /// The asset path relative to the content directory. /// The content manager instance for which to load the asset. + /// The is empty or contains invalid characters. + /// The content asset couldn't be loaded (e.g. because it doesn't exist). public T LoadFor(string assetName, ContentManager instance) { - lock (this.Lock) + // normalise asset key + this.AssertValidAssetKeyFormat(assetName); + assetName = this.NormaliseAssetName(assetName); + + // load game content + if (!assetName.StartsWith(this.ModContentPrefix)) + return this.LoadImpl(assetName, instance); + + // load mod content + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading content asset '{assetName}': {reasonPhrase}."); + try { - assetName = this.NormaliseAssetName(assetName); + return this.WithWriteLock(() => + { + // try cache + if (this.IsLoaded(assetName)) + return this.LoadImpl(assetName, instance); - // skip if already loaded - if (this.IsNormalisedKeyLoaded(assetName)) - { - this.TrackAssetLoader(assetName, instance); - return base.Load(assetName); - } + // get file + FileInfo file = this.GetModFile(assetName); + if (!file.Exists) + throw GetContentError("the specified path doesn't exist."); - // 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(assetName); - } - else - { - data = this.AssetsBeingLoaded.Track(assetName, () => + // load content + switch (file.Extension.ToLower()) { - IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); - IAssetData asset = this.ApplyLoader(info) ?? new AssetDataForObject(info, base.Load(assetName), this.NormaliseAssetName); - asset = this.ApplyEditors(info, asset); - return (T)asset.Data; - }); - } + // XNB file + case ".xnb": + return this.LoadImpl(assetName, instance); - // update cache & return data - this.Cache[assetName] = data; - this.TrackAssetLoader(assetName, instance); - return data; + // unpacked map + case ".tbin": + throw GetContentError($"can't read unpacked map file '{assetName}' directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + + // unpacked image + case ".png": + // validate + if (typeof(T) != typeof(Texture2D)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + + // fetch & cache + using (FileStream stream = File.OpenRead(file.FullName)) + { + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + this.InjectWithoutLock(assetName, texture, instance); + return (T)(object)texture; + } + + default: + throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); + } + }); + } + catch (Exception ex) when (!(ex is SContentLoadException)) + { + throw new SContentLoadException($"The content manager failed loading content asset '{assetName}'.", ex); } } @@ -193,40 +262,15 @@ namespace StardewModdingAPI.Framework /// The type of asset to inject. /// The asset path relative to the loader root directory, not including the .xnb extension. /// The asset value. - public void Inject(string assetName, T value) + /// The content manager instance for which to load the asset. + public void Inject(string assetName, T value, ContentManager instance) { - lock (this.Lock) - { - assetName = this.NormaliseAssetName(assetName); - this.Cache[assetName] = value; - this.TrackAssetLoader(assetName, this); - } - } - - /// Get the current content locale. - public string GetLocale() - { - return this.GetKeyLocale.Invoke(); - } - - /// Get the cached asset keys. - public IEnumerable GetAssetKeys() - { - lock (this.Lock) - { - IEnumerable GetAllAssetKeys() - { - foreach (string cacheKey in this.Cache.Keys) - { - this.ParseCacheKey(cacheKey, out string assetKey, out string _); - yield return assetKey; - } - } - - return GetAllAssetKeys().Distinct(); - } + this.WithWriteLock(() => this.InjectWithoutLock(assetName, value, instance)); } + /**** + ** Cache invalidation + ****/ /// Purge assets from the cache that match one of the interceptors. /// The asset editors for which to purge matching assets. /// The asset loaders for which to purge matching assets. @@ -239,21 +283,34 @@ namespace StardewModdingAPI.Framework // get CanEdit/Load methods MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit)); MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad)); + if (canEdit == null || canLoad == null) + throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen // invalidate matching keys - return this.InvalidateCache((assetName, assetType) => + return this.InvalidateCache(asset => { - // 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 }))) + MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType); + if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { asset }))) return true; // check editors - MethodInfo canEditGeneric = canEdit.MakeGenericMethod(assetType); - return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { info })); + MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType); + return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { asset })); + }); + } + + /// Purge matched assets from the cache. + /// Matches the asset keys to invalidate. + /// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game. + /// Returns whether any cache entries were invalidated. + public bool InvalidateCache(Func predicate, bool dispose = false) + { + string locale = this.GetLocale(); + return this.InvalidateCache((assetName, type) => + { + IAssetInfo info = new AssetInfo(locale, assetName, type, this.NormaliseAssetName); + return predicate(info); }); } @@ -263,83 +320,81 @@ namespace StardewModdingAPI.Framework /// Returns whether any cache entries were invalidated. public bool InvalidateCache(Func predicate, bool dispose = false) { - lock (this.Lock) + return this.WithWriteLock(() => { - // find matching asset keys - HashSet purgeCacheKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase); - HashSet purgeAssetKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase); - foreach (string cacheKey in this.Cache.Keys) + // invalidate matching keys + HashSet removeKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase); + HashSet removeAssetNames = new HashSet(StringComparer.InvariantCultureIgnoreCase); + this.Cache.Remove((key, type) => { - this.ParseCacheKey(cacheKey, out string assetKey, out _); - Type type = this.Cache[cacheKey].GetType(); - if (predicate(assetKey, type)) + this.ParseCacheKey(key, out string assetName, out _); + if (removeAssetNames.Contains(assetName) || predicate(assetName, type)) { - purgeAssetKeys.Add(assetKey); - purgeCacheKeys.Add(cacheKey); + removeAssetNames.Add(assetName); + removeKeys.Add(key); + return true; } - } + return false; + }); - // 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); - } + // update reference tracking + foreach (string key in removeKeys) + this.ContentManagersByAssetKey.Remove(key); // reload core game assets int reloaded = 0; - foreach (string key in purgeAssetKeys) + foreach (string key in removeAssetNames) { if (this.CoreAssets.ReloadForKey(this, key)) reloaded++; } // report result - if (purgeCacheKeys.Any()) + if (removeKeys.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); + this.Monitor.Log($"Invalidated {removeAssetNames.Count} asset names: {string.Join(", ", removeKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace); return true; } this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace); return false; - } + }); } + /**** + ** Disposal + ****/ /// Dispose assets for the given content manager shim. /// The content manager whose assets to dispose. 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); + this.WithWriteLock(() => + { + foreach (var entry in this.ContentManagersByAssetKey) + entry.Value.Remove(shim); + this.InvalidateCache((key, type) => !this.ContentManagersByAssetKey[key].Any(), dispose: true); + }); } /********* ** Private methods *********/ - /// Get whether an asset has already been loaded. - /// The normalised asset name. - private bool IsNormalisedKeyLoaded(string normalisedAssetName) + /**** + ** Disposal + ****/ + /// Dispose held resources. + /// Whether the content manager is disposing (rather than finalising). + protected override void Dispose(bool disposing) { - return this.Cache.ContainsKey(normalisedAssetName) - || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset - } - - /// Track that a content manager loaded an asset. - /// The asset key that was loaded. - /// The content manager that loaded the asset. - private void TrackAssetLoader(string key, ContentManager manager) - { - if (!this.AssetLoaders.TryGetValue(key, out HashSet hash)) - hash = this.AssetLoaders[key] = new HashSet(); - hash.Add(manager); + this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace); + base.Dispose(disposing); } + /**** + ** Asset name/key handling + ****/ /// Get the locale codes (like ja-JP) used in asset keys. /// Simplifies access to private game code. private IDictionary GetKeyLocales(Reflector reflection) @@ -367,11 +422,19 @@ namespace StardewModdingAPI.Framework return map; } + /// Get the asset name from a cache key. + /// The input cache key. + private string GetAssetName(string cacheKey) + { + this.ParseCacheKey(cacheKey, out string assetName, out string _); + return assetName; + } + /// Parse a cache key into its component parts. /// The input cache key. - /// The original asset key. + /// The original asset name. /// The asset locale code (or null if not localised). - private void ParseCacheKey(string cacheKey, out string assetKey, out string localeCode) + private void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) { // handle localised key if (!string.IsNullOrWhiteSpace(cacheKey)) @@ -382,7 +445,7 @@ namespace StardewModdingAPI.Framework string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); if (this.KeyLocales.ContainsKey(suffix)) { - assetKey = cacheKey.Substring(0, lastSepIndex); + assetName = cacheKey.Substring(0, lastSepIndex); localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); return; } @@ -390,10 +453,117 @@ namespace StardewModdingAPI.Framework } // handle simple key - assetKey = cacheKey; + assetName = cacheKey; localeCode = null; } + /**** + ** Cache handling + ****/ + /// Get whether an asset has already been loaded. + /// The normalised asset name. + private bool IsNormalisedKeyLoaded(string normalisedAssetName) + { + return this.Cache.ContainsKey(normalisedAssetName) + || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset + } + + /// Track that a content manager loaded an asset. + /// The asset key that was loaded. + /// The content manager that loaded the asset. + private void TrackAssetLoader(string key, ContentManager manager) + { + if (!this.ContentManagersByAssetKey.TryGetValue(key, out HashSet hash)) + hash = this.ContentManagersByAssetKey[key] = new HashSet(); + hash.Add(manager); + } + + /**** + ** Content loading + ****/ + /// Load an asset name without heuristics to support mod content. + /// The type of asset to load. + /// The asset path relative to the loader root directory, not including the .xnb extension. + /// The content manager instance for which to load the asset. + private T LoadImpl(string assetName, ContentManager instance) + { + return this.WithWriteLock(() => + { + // skip if already loaded + if (this.IsNormalisedKeyLoaded(assetName)) + { + this.TrackAssetLoader(assetName, instance); + return base.Load(assetName); + } + + // 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(assetName); + } + else + { + data = this.AssetsBeingLoaded.Track(assetName, () => + { + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = this.ApplyLoader(info) ?? new AssetDataForObject(info, base.Load(assetName), this.NormaliseAssetName); + asset = this.ApplyEditors(info, asset); + return (T)asset.Data; + }); + } + + // update cache & return data + this.InjectWithoutLock(assetName, data, instance); + return data; + }); + } + + /// Inject an asset into the cache without acquiring a write lock. This should only be called from within a write lock. + /// The type of asset to inject. + /// The asset path relative to the loader root directory, not including the .xnb extension. + /// The asset value. + /// The content manager instance for which to load the asset. + private void InjectWithoutLock(string assetName, T value, ContentManager instance) + { + assetName = this.NormaliseAssetName(assetName); + this.Cache[assetName] = value; + this.TrackAssetLoader(assetName, instance); + } + + /// Get a file from the mod folder. + /// The asset path relative to the content folder. + private FileInfo GetModFile(string path) + { + // try exact match + FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path)); + + // try with default extension + if (!file.Exists && file.Extension.ToLower() != ".xnb") + { + FileInfo result = new FileInfo(file.FullName + ".xnb"); + if (result.Exists) + file = result; + } + + return file; + } + + /// Get a file from the game's content folder. + /// The asset key. + private FileInfo GetContentFolderFile(string key) + { + // get file path + string path = Path.Combine(this.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path); + } + /// Load the initial asset from the registered . /// The basic asset metadata. /// Returns the loaded asset metadata, or null if no loader matched. @@ -510,25 +680,123 @@ namespace StardewModdingAPI.Framework { foreach (var entry in entries) { - IModMetadata metadata = entry.Key; + IModMetadata mod = entry.Key; IList interceptors = entry.Value; - // special case if mod is an interceptor - if (metadata.Mod is T modAsInterceptor) - yield return new KeyValuePair(metadata, modAsInterceptor); - // registered editors foreach (T interceptor in interceptors) - yield return new KeyValuePair(metadata, interceptor); + yield return new KeyValuePair(mod, interceptor); } } - /// Dispose held resources. - /// Whether the content manager is disposing (rather than finalising). - protected override void Dispose(bool disposing) + /// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing. + /// The texture to premultiply. + /// Returns a premultiplied texture. + /// Based on code by Layoric. + private Texture2D PremultiplyTransparency(Texture2D texture) { - this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace); - base.Dispose(disposing); + // validate + if (Context.IsInDrawLoop) + throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); + + // process texture + SpriteBatch spriteBatch = Game1.spriteBatch; + GraphicsDevice gpu = Game1.graphics.GraphicsDevice; + using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) + { + // create blank render target to premultiply + gpu.SetRenderTarget(renderTarget); + gpu.Clear(Color.Black); + + // multiply each color by the source alpha, and write just the color values into the final texture + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorDestinationBlend = Blend.Zero, + ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, + AlphaDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.SourceAlpha, + ColorSourceBlend = Blend.SourceAlpha + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // copy the alpha values from the source texture into the final one without multiplying them + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorWriteChannels = ColorWriteChannels.Alpha, + AlphaDestinationBlend = Blend.Zero, + ColorDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.One, + ColorSourceBlend = Blend.One + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // release GPU + gpu.SetRenderTarget(null); + + // extract premultiplied data + Color[] data = new Color[texture.Width * texture.Height]; + renderTarget.GetData(data); + + // unset texture from GPU to regain control + gpu.Textures[0] = null; + + // update texture with premultiplied data + texture.SetData(data); + } + + return texture; + } + + /**** + ** Concurrency logic + ****/ + /// Acquire a read lock which prevents concurrent writes to the cache while it's open. + /// The action's return value. + /// The action to perform. + private T WithReadLock(Func action) + { + try + { + this.Lock.EnterReadLock(); + return action(); + } + finally + { + this.Lock.ExitReadLock(); + } + } + + /// Acquire a write lock which prevents concurrent reads or writes to the cache while it's open. + /// The action to perform. + private void WithWriteLock(Action action) + { + try + { + this.Lock.EnterWriteLock(); + action(); + } + finally + { + this.Lock.ExitWriteLock(); + } + } + + /// Acquire a write lock which prevents concurrent reads or writes to the cache while it's open. + /// The action's return value. + /// The action to perform. + private T WithWriteLock(Func action) + { + try + { + this.Lock.EnterReadLock(); + return action(); + } + finally + { + this.Lock.ExitReadLock(); + } } } } diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 6f8f7cef..c886a4b7 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -12,7 +12,6 @@ using Microsoft.Xna.Framework.Input; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; -using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Locations; @@ -180,7 +179,7 @@ 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.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor, reflection); this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content"); Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content"); reflection.GetPrivateField(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager @@ -241,6 +240,9 @@ namespace StardewModdingAPI.Framework return; } + /********* + ** Save events + suppress events during save + *********/ // While the game is writing to the save file in the background, mods can unexpectedly // fail since they don't have exclusive access to resources (e.g. collection changed // during enumeration errors). To avoid problems, events are not invoked while a save @@ -249,7 +251,7 @@ namespace StardewModdingAPI.Framework if (Context.IsSaving) { // raise before-save - if (!this.IsBetweenSaveEvents) + if (Context.IsWorldReady && !this.IsBetweenSaveEvents) { this.IsBetweenSaveEvents = true; this.Monitor.Log("Context: before save.", LogLevel.Trace); @@ -371,7 +373,8 @@ namespace StardewModdingAPI.Framework SButton[] previousPressedKeys = this.PreviousPressedButtons; SButton[] framePressedKeys = currentlyPressedKeys.Except(previousPressedKeys).ToArray(); SButton[] frameReleasedKeys = previousPressedKeys.Except(currentlyPressedKeys).ToArray(); - bool isClick = framePressedKeys.Contains(SButton.MouseLeft) || (framePressedKeys.Contains(SButton.ControllerA) && !currentlyPressedKeys.Contains(SButton.ControllerX)); + bool isUseToolButton = Game1.options.useToolButton.Any(p => framePressedKeys.Contains(p.ToSButton())); + bool isActionButton = !isUseToolButton && Game1.options.actionButton.Any(p => framePressedKeys.Contains(p.ToSButton())); // get cursor position ICursorPosition cursor; @@ -388,7 +391,7 @@ namespace StardewModdingAPI.Framework // raise button pressed foreach (SButton button in framePressedKeys) { - InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isClick); + InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isActionButton, isUseToolButton); // legacy events if (button.TryGetKeyboard(out Keys key)) @@ -408,10 +411,9 @@ namespace StardewModdingAPI.Framework // raise button released foreach (SButton button in frameReleasedKeys) { - bool wasClick = - (button == SButton.MouseLeft && previousPressedKeys.Contains(SButton.MouseLeft)) // released left click - || (button == SButton.ControllerA && previousPressedKeys.Contains(SButton.ControllerA) && !previousPressedKeys.Contains(SButton.ControllerX)); - InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasClick); + bool wasUseToolButton = (from opt in Game1.options.useToolButton let optButton = opt.ToSButton() where optButton == button && framePressedKeys.Contains(optButton) select optButton).Any(); + bool wasActionButton = !wasUseToolButton && (from opt in Game1.options.actionButton let optButton = opt.ToSButton() where optButton == button && framePressedKeys.Contains(optButton) select optButton).Any(); + InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasActionButton, wasUseToolButton); // legacy events if (button.TryGetKeyboard(out Keys key)) diff --git a/src/SMAPI/Framework/Serialisation/JsonHelper.cs b/src/SMAPI/Framework/Serialisation/JsonHelper.cs index 3193aa3c..d923ec0c 100644 --- a/src/SMAPI/Framework/Serialisation/JsonHelper.cs +++ b/src/SMAPI/Framework/Serialisation/JsonHelper.cs @@ -1,9 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using Microsoft.Xna.Framework.Input; using Newtonsoft.Json; -using StardewModdingAPI.Utilities; namespace StardewModdingAPI.Framework.Serialisation { @@ -20,7 +19,9 @@ namespace StardewModdingAPI.Framework.Serialisation ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection values are duplicated each time the config is loaded Converters = new List { - new SelectiveStringEnumConverter(typeof(Buttons), typeof(Keys), typeof(SButton)) + new StringEnumConverter(), + new StringEnumConverter(), + new StringEnumConverter() } }; diff --git a/src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs b/src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs deleted file mode 100644 index 37108556..00000000 --- a/src/SMAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Converters; - -namespace StardewModdingAPI.Framework.Serialisation -{ - /// A variant of which only converts certain enums. - internal class SelectiveStringEnumConverter : StringEnumConverter - { - /********* - ** Properties - *********/ - /// The enum type names to convert. - private readonly HashSet Types; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The enum types to convert. - public SelectiveStringEnumConverter(params Type[] types) - { - this.Types = new HashSet(types.Select(p => p.FullName)); - } - - /// Get whether this instance can convert the specified object type. - /// The object type. - public override bool CanConvert(Type type) - { - return - base.CanConvert(type) - && this.Types.Contains((Nullable.GetUnderlyingType(type) ?? type).FullName); - } - } -} diff --git a/src/SMAPI/Framework/Serialisation/StringEnumConverter.cs b/src/SMAPI/Framework/Serialisation/StringEnumConverter.cs new file mode 100644 index 00000000..7afe86cd --- /dev/null +++ b/src/SMAPI/Framework/Serialisation/StringEnumConverter.cs @@ -0,0 +1,22 @@ +using System; +using Newtonsoft.Json.Converters; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// A variant of which only converts a specified enum. + /// The enum type. + internal class StringEnumConverter : StringEnumConverter + { + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type type) + { + return + base.CanConvert(type) + && (Nullable.GetUnderlyingType(type) ?? type) == typeof(T); + } + } +} diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index b78b165b..e3362502 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewValley; +using xTile; namespace StardewModdingAPI { @@ -29,7 +30,7 @@ namespace StardewModdingAPI ** Public methods *********/ /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. + /// The expected data type. The main supported types are , , and dictionaries; other types may be supported by the game's content pipeline. /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. /// Where to search for a matching content asset. /// The is empty or contains invalid characters. @@ -52,5 +53,10 @@ namespace StardewModdingAPI /// The asset type to remove from the cache. /// Returns whether any assets were invalidated. bool InvalidateCache(); + + /// Remove matching assets from the content cache so they're 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. + /// A predicate matching the assets to invalidate. + /// Returns whether any cache entries were invalidated. + bool InvalidateCache(Func predicate); } } diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index fe306e24..b742467b 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Net; using System.Reflection; using System.Runtime.ExceptionServices; using System.Security; +using System.Text.RegularExpressions; using System.Threading; #if SMAPI_FOR_WINDOWS using System.Management; @@ -77,6 +79,13 @@ namespace StardewModdingAPI /// Whether the program has been disposed. private bool IsDisposed; + /// Regex patterns which match console messages to suppress from the console and log. + private readonly Regex[] SuppressConsolePatterns = + { + new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant) + }; + /********* ** Public methods @@ -510,8 +519,11 @@ namespace StardewModdingAPI } catch (Exception ex) { - this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log($"Error: {ex.GetLogSummary()}"); + this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? $"Error: {ex.Message}" + : $"Error: {ex.GetLogSummary()}" + ); } // check mod versions @@ -598,7 +610,11 @@ namespace StardewModdingAPI } catch (Exception ex) { - this.Monitor.Log($"Couldn't check for new mod versions:\n{ex.GetLogSummary()}", LogLevel.Trace); + this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? ex.Message + : ex.ToString() + ); } }).Start(); } @@ -910,7 +926,14 @@ namespace StardewModdingAPI /// The message to log. private void HandleConsoleMessage(IMonitor monitor, string message) { - LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; // intercept potential exceptions + // detect exception + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; + + // ignore suppressed message + if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) + return; + + // forward to monitor monitor.Log(message, level); } diff --git a/src/SMAPI/SButton.cs b/src/SMAPI/SButton.cs index 0ec799db..bd6635c7 100644 --- a/src/SMAPI/SButton.cs +++ b/src/SMAPI/SButton.cs @@ -615,6 +615,18 @@ namespace StardewModdingAPI return (SButton)(SButtonExtensions.ControllerOffset + key); } + /// Get the equivalent for the given button. + /// The Stardew Valley button to convert. + internal static SButton ToSButton(this InputButton input) + { + // derived from InputButton constructors + if (input.mouseLeft) + return SButton.MouseLeft; + if (input.mouseRight) + return SButton.MouseRight; + return input.key.ToSButton(); + } + /// Get the equivalent for the given button. /// The button to convert. /// The keyboard equivalent. diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index ce86dceb..4826c947 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -49,6 +49,12 @@ namespace StardewModdingAPI public SemanticVersion(string version) : this(new SemanticVersionImpl(version)) { } + /// Construct an instance. + /// The assembly version. + /// The is null. + public SemanticVersion(Version version) + : this(new SemanticVersionImpl(version)) { } + /// Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version. /// The version to compare with this instance. /// The value is null. diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index b8d5990e..605292b2 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -89,6 +89,7 @@ Properties\GlobalAssemblyInfo.cs + @@ -173,7 +174,7 @@ - + diff --git a/src/TrainerMod/Properties/AssemblyInfo.cs b/src/TrainerMod/Properties/AssemblyInfo.cs deleted file mode 100644 index 0b19e78a..00000000 --- a/src/TrainerMod/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("TrainerMod")] -[assembly: AssemblyDescription("")] -[assembly: Guid("76791e28-b1b5-407c-82d6-50c3e5b7e037")] \ No newline at end of file