From 63319f67a6341a96469d60c4ae5354c8efbe6eb7 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 13 Apr 2025 19:12:48 -0400 Subject: [PATCH 1/6] revamp mod package readme This makes it easier to see the features at a glance, and collects the info for each feature into one place. --- docs/technical/mod-package-release-notes.md | 6 +- docs/technical/mod-package.md | 325 +++++++++++--------- 2 files changed, 185 insertions(+), 146 deletions(-) diff --git a/docs/technical/mod-package-release-notes.md b/docs/technical/mod-package-release-notes.md index 0290d7b6..d88b7e11 100644 --- a/docs/technical/mod-package-release-notes.md +++ b/docs/technical/mod-package-release-notes.md @@ -1,7 +1,11 @@ ← [mod build config](./mod-build-config.md) ## Release notes -# 4.3.2 +## Upcoming release +* Revamped [package documentation](mod-package.md). + _This makes it easier to see the features at a glance, and collects the info for each feature into one place._ + +## 4.3.2 Released 09 November 2024 for SMAPI 4.1.0 or later. * Fixed `IgnoreModFilePaths` and `IgnoreModFilePatterns` not working correctly in recent versions. diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 54db82e7..398f6d83 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -4,21 +4,22 @@ The **mod build package** is an open-source NuGet package which automates the MS for SMAPI mods and related tools. The package is fully compatible with Linux, macOS, and Windows. ## Contents -* [Use](#use) +* [Basic usage](#basic-usage) * [Features](#features) + * [Detect game path](#detect-game-path) + * [Install and debug your mod](#install-and-debug-your-mod) + * [Release zip](#release-zip) + * [Bundled content packs](#bundled-content-packs) + * [Code warnings](#code-warnings) + * [Unit tests](#unit-tests) + * [Other features](#other-features) * [Configure](#configure) * [How to set options](#how-to-set-options) * [Available properties](#available-properties) -* [Bundle content packs](#bundle-content-packs) -* [Code warnings](#code-warnings) -* [FAQs](#faqs) - * [How do I set the game path?](#custom-game-path) - * [How do I change which files are included in the mod deploy/zip?](#how-do-i-change-which-files-are-included-in-the-mod-deployzip) - * [Can I use the package for non-mod projects?](#can-i-use-the-package-for-non-mod-projects) * [For SMAPI developers](#for-smapi-developers) -* [Release notes](#release-notes) +* [See also](#see-also) -## Use +## Basic usage 1. Create an empty library project. 2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig). 3. [Write your code](https://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod). @@ -27,45 +28,176 @@ for SMAPI mods and related tools. The package is fully compatible with Linux, ma ## Features The package includes several features to simplify mod development (see [_configure_](#configure) to -change how these work): +change how these work). -* **Detect game path:** - The package automatically finds your game folder by scanning the default install paths and - Windows registry. It adds two MSBuild properties for use in your `.csproj` file if needed: - `$(GamePath)` and `$(GameModsPath)`. +### Detect game path +The package... +- finds your game folder by scanning the default install paths and Windows registry; +- sets up your project so the code can reference SMAPI and game code; +- and adds two MSBuild properties for use in your `.csproj` file if needed: `$(GamePath)` and `$(GameModsPath)`. -* **Add assembly references:** - The package adds assembly references to MonoGame, SMAPI, Stardew Valley, and xTile. It - automatically adjusts depending on which OS you're compiling it on. If you use - [Harmony](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Harmony), it can optionally add - a reference to that too. +> [!TIP] +> If it can't find your game or you have multiple installs, you can specify the path yourself. To +> do that: +> +> 1. Find your computer's home folder (see instructions for [Linux](https://superuser.com/a/409223), +> [macOS](https://www.cnet.com/how-to/how-to-find-your-macs-home-folder-and-add-it-to-finder/), +> or [Windows](https://www.computerhope.com/issues/ch000109.htm)). +> 2. Create a `stardewvalley.targets` file with this content: +> ```xml +> +> +> PATH_HERE +> +> +> ``` +> 3. Replace `PATH_HERE` with the full folder path containing the Stardew Valley executable. +> +> See [_configure_](#configure) for more info. -* **Copy files into the `Mods` folder:** - The package automatically copies your mod's DLL and PDB files, `manifest.json`, [`i18n` - files](https://stardewvalleywiki.com/Modding:Translations) (if any), and the `assets` folder (if - any) into the `Mods` folder when you rebuild the code, with a subfolder matching the mod's project - name. That lets you try the mod in-game right after building it. +### Install and debug your mod +The package automatically installs your mod when you build the code, and sets up Visual Studio so +it can launch the game and debug your code. That lets you try the mod in-game right after building +it. -* **Create release zip:** - The package adds a zip file in your project's `bin` folder when you rebuild the code, in the - format recommended for uploading to mod sites like Nexus Mods. This includes the same files as - the previous feature. +> [!TIP] +> Here's how to use the features in Visual Studio. +> +>
+>
To install and run your mod:
+>
+> +> 1. Open the code in Visual Studio. +> 2. Click _Build > Rebuild Solution_ to copy the mod files into your game folder. +> 3. Click _Debug > Start Without Debugging_ to launch the game. +> +>
+> +>
To debug your mod code:
+>
+> +> On Windows only, use _Debug > Start Debugging_ instead to launch the game with a debugger. That +> lets you [set breakpoints](https://docs.microsoft.com/en-us/visualstudio/debugger/using-breakpoints) +> or [make simple changes code without needing to restart the game](https://docs.microsoft.com/en-us/visualstudio/debugger/edit-and-continue). +> +> (Debugging is disabled on Linux/macOS due to limitations with the Mono wrapper.) +> +>
+>
-* **Launch or debug mod:** - On Windows only, the package configures Visual Studio so you can launch the game and attach a - debugger using _Debug > Start Debugging_ or _Debug > Start Without Debugging_. This lets you [set - breakpoints](https://docs.microsoft.com/en-us/visualstudio/debugger/using-breakpoints?view=vs-2019) - in your code while the game is running, or [make simple changes to the mod code without needing to - restart the game](https://docs.microsoft.com/en-us/visualstudio/debugger/edit-and-continue?view=vs-2019). - This is disabled on Linux/macOS due to limitations with the Mono wrapper. +> [!TIP] +> The package automatically detects which files need to be included. If you need to change that: +> * For normal files, you can [add/remove them in the build output](https://stackoverflow.com/a/10828462/262123). +> * For assembly files (`*.dll`, `*.exe`, `*.pdb`, or `*.xml`), see the [`BundleExtraAssemblies` +> option](#configure). +> * To exclude a file which the package copies by default, see the [`IgnoreModFilePaths` or +> `IgnoreModFilePatterns` options](#configure). -* **Preconfigure common settings:** - The package automatically enables `.pdb` files (so error logs show line numbers to simplify - debugging), and enables support for the simplified SDK-style `.csproj` format. +### Release zip +The package adds a zip file in your project's `bin` folder when you rebuild the code, in the format +recommended for uploading to mod sites like Nexus Mods. -* **Add code warnings:** - The package runs code analysis on your mod and raises warnings for some common errors or - pitfalls. See [_code warnings_](#code-warnings) for more info. +See [_install and debug your mod_](#deploy) for more info. You can also [change where the zips are +stored](#configure). + +### Bundled content packs +You can bundle any number of [content packs](https://stardewvalleywiki.com/Modding:Content_pack_frameworks) +with your main C# mod. They'll be grouped with the main mod into a parent folder automatically, +which will be [copied to the `Mods` folder](#deploy) and included in [the release zip](#release-zip). + +To do that, add an `ItemGroup` with a `ContentPacks` line for each content pack you want to include: + +```xml + + + + +``` + +You can use these properties for each line: + +property | effect +----------------------- | ------ +`Include` | _(Required)_ The path to the content pack folder. This can be an absolute path, or relative to the current project. +`Version` | _(Required)_ The expected version of the content pack. This should usually be the same version as your main mod, to keep update alerts in sync. The package will validate that the included content pack's manifest version matches. +`FolderName` | _(Optional)_ The content pack folder name to create. Defaults to the folder name from `Include`. +`ValidateManifest` | _(Optional)_ Whether to validate that the included mod has a valid `manifest.json` file and version. Default `true`. +`IgnoreModFilePaths` | _(Optional)_ A list of file paths to ignore (relative to the content pack's directory); see `IgnoreModFilePaths` in the main settings. Default none. +`IgnoreModFilePatterns` | _(Optional)_ A list of file regex patterns to ignore (relative to the content pack's directory); see `IgnoreModFilePatterns` in the main settings. Default none. + +### Code warnings +The package runs code analysis on your mod and raises warnings for some common errors or pitfalls +specific to Stardew Valley modding. + +For example: +> ![](screenshots/code-analyzer-example.png) + +You can [hide the warnings](https://visualstudiomagazine.com/articles/2017/09/01/hide-compiler-warnings.aspx) +if needed using the warning ID (shown under 'code' in Visual Studio's Error List). + +Warnings raised: + + + + + + + + + + + + + + + + + + +
errorinfo
Avoid net field + +Warning text: +> '{{expression}}' is a {{net type}} field; consider using the {{property name}} property instead. + +Your code accesses a net field, but the game has an equivalent non-net property. + +Suggested fix: access the suggested property name instead. + +
Avoid obsolete field + +Warning text: +> The '{{old field}}' field is obsolete and should be replaced with '{{new field}}'. + +Your code accesses a field which is obsolete or no longer works. Use the suggested field instead. + +
Wrong processor architecture + +Warning text: +> The target platform should be set to 'Any CPU' for compatibility with both 32-bit and 64-bit +> versions of Stardew Valley (currently set to '{{current platform}}'). + +Mods can be used in either 32-bit or 64-bit mode. Your project's target platform isn't set to the +default 'Any CPU', so it won't work in both. You can fix it by [setting the target platform to +'Any CPU'](https://docs.microsoft.com/en-ca/visualstudio/ide/how-to-configure-projects-to-target-platforms). + +
+ +### Unit tests +You can use the projects to set up unit test and framework projects too. (Note that you still need +a game install on the machine running the tests.) + +Recommended settings for a unit test project (see [_configure_](#configure)): +```xml +false +false +false +All +``` + +### Other features +The package preconfigures your Visual Studio project using the recommended settings for Stardew +Valley mods. For example, it enables debug symbols so error logs show line numbers to simplify +debugging. ## Configure ### How to set options @@ -83,7 +215,7 @@ There are two places you can put them: for properties you want to set for all mods (e.g. a custom game path). Here's where to put them: 1. Open the home folder on your computer (see instructions for - [Linux](https://superuser.com/questions/409218/where-is-my-users-home-folder-in-ubuntu), + [Linux](https://superuser.com/a/409223), [macOS](https://www.cnet.com/how-to/how-to-find-your-macs-home-folder-and-add-it-to-finder/), or [Windows](https://www.computerhope.com/issues/ch000109.htm)). 2. Create a `stardewvalley.targets` file with this content: @@ -118,7 +250,7 @@ property | effect #### Mod build properties property | effect ----------------- | ------ -`EnableHarmony` | Whether to add a reference to [Harmony](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Harmony) (default `false`). This is only needed if you use Harmony. +`EnableHarmony` | Whether to add a reference to [Harmony][] (default `false`). This is only needed if you use Harmony. `EnableModDeploy` | Whether to copy the mod files into your game's `Mods` folder (default `true`). `EnableModZip` | Whether to create a release-ready `.zip` file in the mod project's `bin` folder (default `true`). `ModFolderName` | The mod name for its folder under `Mods` and its release zip (defaults to the project name). @@ -134,10 +266,11 @@ property | effect `IgnoreModFilePatterns` | A comma-delimited list of regex patterns matching files to ignore when deploying or zipping the mod files (default empty). For crossplatform compatibility, you should replace path delimiters with `[/\\]`. For example, this excludes all `.txt` and `.pdf` files, as well as the `assets/paths.png` file: `\.txt$, \.pdf$, assets[/\\]paths.png`. #### `BundleExtraAssemblies` -_(Specialized)_ **Most mods should not change this option.** +> [!IMPORTANT] +> This is a specialized option. Most mods should not change this option. By default (when this is _not_ enabled), only the mod files [normally considered part of the -mod](#Features) will be added to the release `.zip` and copied into the `Mods` folder (i.e. +mod](#deploy) will be added to the release `.zip` and copied into the `Mods` folder (i.e. "deployed"). That includes the assembly files (`*.dll`, `*.pdb`, and `*.xml`) for your mod project, but any other DLLs won't be deployed. @@ -157,106 +290,6 @@ with their mod. The other options are mainly useful for unit tests. When enabling this option, you should **manually review which files get deployed** and use the `IgnoreModFilePaths` or `IgnoreModFilePatterns` options to exclude files as needed. -## Bundle content packs -You can bundle any number of [content packs](https://stardewvalleywiki.com/Modding:Content_pack_frameworks) -with your main C# mod. They'll be grouped with the main mod into a parent folder automatically, -which will be copied to the `Mods` folder and included in the release zip. - -To do that, add an `ItemGroup` with a `ContentPacks` line for each content pack you want to include: - -```xml - - - - -``` - -You can use these properties for each line: - -property | effect ------------------------ | ------ -`Include` | _(Required)_ The path to the content pack folder. This can be an absolute path, or relative to the current project. -`Version` | _(Required)_ The expected version of the content pack. This should usually be the same version as your main mod, to keep update alerts in sync. The package will validate that the included content pack's manifest version matches. -`FolderName` | _(Optional)_ The content pack folder name to create. Defaults to the folder name from `Include`. -`ValidateManifest` | _(Optional)_ Whether to validate that the included mod has a valid `manifest.json` file and version. Default `true`. -`IgnoreModFilePaths` | _(Optional)_ A list of file paths to ignore (relative to the content pack's directory); see `IgnoreModFilePaths` in the main settings. Default none. -`IgnoreModFilePatterns` | _(Optional)_ A list of file regex patterns to ignore (relative to the content pack's directory); see `IgnoreModFilePatterns` in the main settings. Default none. - -## Code warnings -### Overview -The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example: -![](screenshots/code-analyzer-example.png) - -You can [hide the warnings](https://visualstudiomagazine.com/articles/2017/09/01/hide-compiler-warnings.aspx) -if needed using the warning ID (shown under 'code' in the Error List). - -See below for help with specific warnings. - -### Avoid net field -Warning text: -> '{{expression}}' is a {{net type}} field; consider using the {{property name}} property instead. - -Your code accesses a net field, but the game has an equivalent non-net property. - -Suggested fix: access the suggested property name instead. - -### Avoid obsolete field -Warning text: -> The '{{old field}}' field is obsolete and should be replaced with '{{new field}}'. - -Your code accesses a field which is obsolete or no longer works. Use the suggested field instead. - -### Wrong processor architecture -Warning text: -> The target platform should be set to 'Any CPU' for compatibility with both 32-bit and 64-bit -> versions of Stardew Valley (currently set to '{{current platform}}'). - -Mods can be used in either 32-bit or 64-bit mode. Your project's target platform isn't set to the -default 'Any CPU', so it won't work in both. You can fix it by [setting the target platform to -'Any CPU'](https://docs.microsoft.com/en-ca/visualstudio/ide/how-to-configure-projects-to-target-platforms). - -## FAQs -### How do I set the game path? -The package detects where your game is installed automatically, so you usually don't need to set it -manually. If it can't find your game or you have multiple installs, you can specify the path -yourself. - -To do that: - -1. Get the full folder path containing the Stardew Valley executable. -2. See [_configure_](#configure) to add this property: - ```xml - - PATH_HERE - - ``` -3. Replace `PATH_HERE` with your game's folder path (don't add quotes). - -The configuration will check your custom path first, then fall back to the default paths (so it'll -still compile on a different computer). - -### How do I change which files are included in the mod deploy/zip? -* For normal files, you can [add/remove them in the build output](https://stackoverflow.com/a/10828462/262123). -* For assembly files (`*.dll`, `*.exe`, `*.pdb`, or `*.xml`), see the - [`BundleExtraAssemblies` option](#configure). -* To exclude a file which the package copies by default, see the [`IgnoreModFilePaths` or - `IgnoreModFilePatterns` options](#configure). - -### Can I use the package for non-mod projects? -Yep, this works in unit tests and framework projects too. Just disable the mod-related package -features (see [_configure_](#configure)): - -```xml -false -false -false -``` - -To copy referenced DLLs into your build output for unit tests, add this too: -```xml -All -``` - ## For SMAPI developers The mod build package consists of three projects: @@ -271,3 +304,5 @@ when you compile it. ## See also * [Release notes](mod-package-release-notes.md) + +[Harmony]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Harmony From 064f0ca7d6f257bb8f802104e9d03be3a5f4dbfe Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 13 Apr 2025 19:12:48 -0400 Subject: [PATCH 2/6] encapsulate mod file info in deploy task --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 31 ++++----- .../Framework/BundleFile.cs | 64 +++++++++++++++++++ .../Framework/ContentPackFileManager.cs | 35 +++++----- .../Framework/IModFileManager.cs | 5 +- .../Framework/MainModFileManager.cs | 50 +++++++-------- 5 files changed, 121 insertions(+), 64 deletions(-) create mode 100644 src/SMAPI.ModBuildConfig/Framework/BundleFile.cs diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index f07bd8fe..3d6af22b 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -92,10 +92,10 @@ public class DeployModTask : Task { try { - string manifestPath = Path.Combine(this.ProjectDir, "manifest.json"); + string manifestPath = Path.Combine(this.ProjectDir, BundleFile.ManifestFileName); if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) { - this.Log.LogError("[mod build package] The mod's manifest.json file doesn't exist."); + this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} file doesn't exist."); return false; } manifest = rawManifest; @@ -104,14 +104,14 @@ public class DeployModTask : Task { // log the inner exception, otherwise the message will be generic Exception exToShow = ex.InnerException ?? ex; - this.Log.LogError($"[mod build package] The mod's manifest.json file isn't valid JSON: {exToShow.Message}"); + this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} file isn't valid JSON: {exToShow.Message}"); return false; } // validate manifest fields if (!ManifestValidator.TryValidateFields(manifest, out string error)) { - this.Log.LogError($"[mod build package] The mod's manifest.json file is invalid: {error}"); + this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} file is invalid: {error}"); return false; } } @@ -301,19 +301,12 @@ public class DeployModTask : Task { foreach (var mod in modPackages) { - string relativePath = modPackages.Count == 1 + string folderPath = modPackages.Count == 1 ? outputPath : Path.Combine(outputPath, this.EscapeInvalidFilenameCharacters(mod.Key)); - foreach (var file in mod.Value.GetFiles()) - { - string fromPath = file.Value.FullName; - string toPath = Path.Combine(relativePath, file.Key); - - Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); - - File.Copy(fromPath, toPath, overwrite: true); - } + foreach (BundleFile from in mod.Value.GetFiles()) + from.CopyToFolder(folderPath); } } @@ -330,21 +323,19 @@ public class DeployModTask : Task foreach (var mod in modPackages) { string modFolder = this.EscapeInvalidFilenameCharacters(mod.Key); - foreach (var file in mod.Value.GetFiles()) + foreach (BundleFile from in mod.Value.GetFiles()) { - string relativePath = file.Key; + string relativePath = from.RelativePath; + if (relativePath.Contains('\\')) relativePath = string.Join("/", PathUtilities.GetSegments(relativePath)); // zip files use forward slashes regardless of OS - FileInfo fileInfo = file.Value; - string archivePath = modPackages.Count == 1 ? $"{modFolder}/{relativePath}" : $"{this.ModFolderName}/{modFolder}/{relativePath}"; - using Stream fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); using Stream fileStreamInZip = archive.CreateEntry(archivePath).Open(); - fileStream.CopyTo(fileStreamInZip); + from.CopyToStream(fileStreamInZip); } } } diff --git a/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs b/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs new file mode 100644 index 00000000..a6c4eca0 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; + +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// A file that should be deployed or zipped as part of a mod. +internal class BundleFile +{ + /********* + ** Accessors + *********/ + /// The name of the manifest file. + public const string ManifestFileName = "manifest.json"; + + /// The file's relative path within the mod. + public string RelativePath { get; } + + /// The file to copy from. + public FileInfo File { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file's relative path within the mod. + /// The file to copy from. + public BundleFile(string relativePath, FileInfo file) + { + this.RelativePath = relativePath; + this.File = file; + } + + /// Get whether this entry is for the mod's manifest.json file. + public bool IsModManifest() + { + return BundleFile.IsModManifest(this.RelativePath); + } + + /// Copy the file into a destination folder. + /// The folder path to use as the base for the . + public void CopyToFolder(string folderPath) + { + string toPath = Path.Combine(folderPath, this.RelativePath); + + Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); + this.File.CopyTo(toPath, overwrite: true); + } + + /// Copy the file's contents into a stream. + /// The stream into which to write the file's contents. + public void CopyToStream(Stream stream) + { + using FileStream fromStream = this.File.OpenRead(); + fromStream.CopyTo(stream); + } + + /// Get whether a relative path is for the mod's manifest.json file. + /// The relative path within the mod folder within the mod's folder. + public static bool IsModManifest(string relativePath) + { + return string.Equals(relativePath, BundleFile.ManifestFileName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs index 4e222c77..6d6b6e23 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs @@ -18,11 +18,8 @@ internal class ContentPackFileManager : IModFileManager /********* ** Fields *********/ - /// The name of the manifest file. - private readonly string ManifestFileName = "manifest.json"; - /// The files that are part of the package. - private readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); + private readonly List Files = []; /********* @@ -44,37 +41,43 @@ internal class ContentPackFileManager : IModFileManager throw GetError($"that folder doesn't exist at {projectDirInfo.FullName}"); // collect files - foreach (FileInfo entry in projectDirInfo.GetFiles("*", SearchOption.AllDirectories)) + BundleFile manifestEntry = null; + foreach (FileInfo file in projectDirInfo.GetFiles("*", SearchOption.AllDirectories)) { - string relativePath = PathUtilities.GetRelativePath(projectDirInfo.FullName, entry.FullName); - FileInfo file = entry; + string relativePath = PathUtilities.GetRelativePath(projectDirInfo.FullName, file.FullName); if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns)) - this.Files[relativePath] = file; + { + BundleFile entry = new(relativePath, file); + this.Files.Add(entry); + + if (manifestEntry is null && entry.IsModManifest()) + manifestEntry = entry; + } } // validate manifest if (validateManifest) { // get manifest file - if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile)) - throw GetError($"it has no {this.ManifestFileName} file"); + if (manifestEntry is null) + throw GetError($"it has no {BundleFile.ManifestFileName} file"); // parse file Manifest manifest; try { - new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest rawManifest); + new JsonHelper().ReadJsonFileIfExists(manifestEntry.File.FullName, out Manifest rawManifest); manifest = rawManifest; } catch (JsonReaderException ex) { - throw GetError($"its {this.ManifestFileName} file isn't valid JSON: {ex.InnerException?.Message ?? ex.Message}"); + throw GetError($"its {BundleFile.ManifestFileName} file isn't valid JSON: {ex.InnerException?.Message ?? ex.Message}"); } // validate manifest fields if (!ManifestValidator.TryValidateFields(manifest, out string error)) - throw GetError($"its {this.ManifestFileName} file is invalid: {error}"); + throw GetError($"its {BundleFile.ManifestFileName} file is invalid: {error}"); // validate version if (version == null) @@ -82,7 +85,7 @@ internal class ContentPackFileManager : IModFileManager if (!SemanticVersion.TryParse(version, out ISemanticVersion requiredVersion)) throw GetError($"the provided Version value '{version}' isn't a valid semantic version"); if (manifest.Version.CompareTo(requiredVersion) != 0) - throw GetError($"its {this.ManifestFileName} has version '{manifest.Version}' instead of the required '{requiredVersion}'"); + throw GetError($"its {BundleFile.ManifestFileName} has version '{manifest.Version}' instead of the required '{requiredVersion}'"); } UserErrorException GetError(string reasonPhrase) @@ -92,9 +95,9 @@ internal class ContentPackFileManager : IModFileManager } /// - public IDictionary GetFiles() + public IEnumerable GetFiles() { - return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); + return this.Files; } /// Get whether a content file should be ignored. diff --git a/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs index 1ce6cdc5..1eb0e470 100644 --- a/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs @@ -1,11 +1,10 @@ using System.Collections.Generic; -using System.IO; namespace StardewModdingAPI.ModBuildConfig.Framework; /// Manages the files that are part of a mod in the release package. -public interface IModFileManager +internal interface IModFileManager { /// Get the files in the mod package. - public IDictionary GetFiles(); + public IEnumerable GetFiles(); } diff --git a/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs index 5bd15085..f25832fa 100644 --- a/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs @@ -13,14 +13,11 @@ internal class MainModFileManager : IModFileManager /********* ** Fields *********/ - /// The name of the manifest file. - private readonly string ManifestFileName = "manifest.json"; - /// The files that are part of the package. - private readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); + private readonly List Files = []; /// The file extensions used by assembly files. - private readonly ISet AssemblyFileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + private readonly HashSet AssemblyFileExtensions = new(StringComparer.OrdinalIgnoreCase) { ".dll", ".exe", @@ -29,8 +26,8 @@ internal class MainModFileManager : IModFileManager }; /// The DLLs which match the type. - private readonly ISet GameDllNames = new HashSet - { + private readonly HashSet GameDllNames = + [ // SMAPI "0Harmony", "Mono.Cecil", @@ -56,7 +53,7 @@ internal class MainModFileManager : IModFileManager "Steamworks.NET", "TextCopy", "xTile" - }; + ]; /********* @@ -80,33 +77,36 @@ internal class MainModFileManager : IModFileManager throw new UserErrorException("Could not create mod package because no build output was found."); // collect files - foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir)) + BundleFile manifestEntry = null; + foreach (BundleFile entry in this.GetPossibleFiles(projectDir, targetDir)) { - string relativePath = entry.Item1; - FileInfo file = entry.Item2; + if (!this.ShouldIgnore(entry.File, entry.RelativePath, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, modDllName)) + { + this.Files.Add(entry); - if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, modDllName)) - this.Files[relativePath] = file; + if (manifestEntry is null && entry.IsModManifest()) + manifestEntry = entry; + } } // check for required files if (validateRequiredModFiles) { // manifest - if (!this.Files.ContainsKey(this.ManifestFileName)) - throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output."); + if (manifestEntry is null) + throw new UserErrorException($"Could not create mod package because no {BundleFile.ManifestFileName} was found in the project or build output."); // DLL // ReSharper disable once SimplifyLinqExpression - if (!this.Files.Any(p => !p.Key.EndsWith(".dll"))) + if (!this.Files.Any(p => !p.RelativePath.EndsWith(".dll"))) throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output."); } } /// - public IDictionary GetFiles() + public IEnumerable GetFiles() { - return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); + return this.Files; } @@ -117,15 +117,15 @@ internal class MainModFileManager : IModFileManager /// The folder containing the project files. /// The folder containing the build output. /// Returns tuples containing the relative path within the mod folder, and the file to copy to it. - private IEnumerable> GetPossibleFiles(string projectDir, string targetDir) + private IEnumerable GetPossibleFiles(string projectDir, string targetDir) { // project manifest bool hasProjectManifest = false; { - FileInfo manifest = new(Path.Combine(projectDir, this.ManifestFileName)); + FileInfo manifest = new(Path.Combine(projectDir, BundleFile.ManifestFileName)); if (manifest.Exists) { - yield return Tuple.Create(this.ManifestFileName, manifest); + yield return new BundleFile(BundleFile.ManifestFileName, manifest); hasProjectManifest = true; } } @@ -138,7 +138,7 @@ internal class MainModFileManager : IModFileManager foreach (FileInfo file in translationsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) { string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); - yield return Tuple.Create(relativePath, file); + yield return new BundleFile(relativePath, file); } hasProjectTranslations = true; } @@ -151,7 +151,7 @@ internal class MainModFileManager : IModFileManager foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) { string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); - yield return Tuple.Create(relativePath, file); + yield return new BundleFile(relativePath, file); } hasAssetsFolder = true; } @@ -165,7 +165,7 @@ internal class MainModFileManager : IModFileManager string[] segments = PathUtilities.GetSegments(relativePath); // prefer project manifest/i18n/assets files - if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) + if (hasProjectManifest && BundleFile.IsModManifest(relativePath)) continue; if (hasProjectTranslations && this.EqualsInvariant(segments[0], "i18n")) continue; @@ -173,7 +173,7 @@ internal class MainModFileManager : IModFileManager continue; // add file - yield return Tuple.Create(relativePath, file); + yield return new BundleFile(relativePath, file); } } From 3e3fd8907d39b62d419f28b537712ea1b0c2ce14 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 13 Apr 2025 19:12:48 -0400 Subject: [PATCH 3/6] encapsulate manifest loading in deploy task --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 35 ++--------- .../Framework/ContentPackFileManager.cs | 33 +++------- .../Framework/ManifestHelper.cs | 61 +++++++++++++++++++ 3 files changed, 73 insertions(+), 56 deletions(-) create mode 100644 src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 3d6af22b..3f626178 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -7,11 +7,7 @@ using System.Reflection; using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Newtonsoft.Json; using StardewModdingAPI.ModBuildConfig.Framework; -using StardewModdingAPI.Toolkit.Framework; -using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig; @@ -87,33 +83,12 @@ public class DeployModTask : Task if (!this.EnableModDeploy && !this.EnableModZip) return true; - // validate the manifest file - IManifest manifest; + // read & validate manifest + string manifestPath = Path.Combine(this.ProjectDir, BundleFile.ManifestFileName); + if (!ManifestHelper.TryLoadManifest(manifestPath, out IManifest manifest, out string error)) { - try - { - string manifestPath = Path.Combine(this.ProjectDir, BundleFile.ManifestFileName); - if (!new JsonHelper().ReadJsonFileIfExists(manifestPath, out Manifest rawManifest)) - { - this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} file doesn't exist."); - return false; - } - manifest = rawManifest; - } - catch (JsonReaderException ex) - { - // log the inner exception, otherwise the message will be generic - Exception exToShow = ex.InnerException ?? ex; - this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} file isn't valid JSON: {exToShow.Message}"); - return false; - } - - // validate manifest fields - if (!ManifestValidator.TryValidateFields(manifest, out string error)) - { - this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} file is invalid: {error}"); - return false; - } + this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} is invalid: {error}"); + return false; } // deploy files diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs index 6d6b6e23..48c5a790 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs @@ -3,11 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using Newtonsoft.Json; using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework; -using StardewModdingAPI.Toolkit.Serialization; -using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework; @@ -56,30 +52,15 @@ internal class ContentPackFileManager : IModFileManager } } - // validate manifest + // load manifest + if (manifestEntry is null) + throw GetError($"it has no {BundleFile.ManifestFileName} file"); + if (!ManifestHelper.TryLoadManifest(manifestEntry.File.FullName, out IManifest manifest, out string error)) + throw GetError($"its {BundleFile.ManifestFileName} file is invalid: {error}"); + + // validate manifest version if (validateManifest) { - // get manifest file - if (manifestEntry is null) - throw GetError($"it has no {BundleFile.ManifestFileName} file"); - - // parse file - Manifest manifest; - try - { - new JsonHelper().ReadJsonFileIfExists(manifestEntry.File.FullName, out Manifest rawManifest); - manifest = rawManifest; - } - catch (JsonReaderException ex) - { - throw GetError($"its {BundleFile.ManifestFileName} file isn't valid JSON: {ex.InnerException?.Message ?? ex.Message}"); - } - - // validate manifest fields - if (!ManifestValidator.TryValidateFields(manifest, out string error)) - throw GetError($"its {BundleFile.ManifestFileName} file is invalid: {error}"); - - // validate version if (version == null) throw GetError("no Version value was provided"); if (!SemanticVersion.TryParse(version, out ISemanticVersion requiredVersion)) diff --git a/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs b/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs new file mode 100644 index 00000000..bc546ee1 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Framework; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; + +namespace StardewModdingAPI.ModBuildConfig.Framework; + +/// Provides utilities for reading and validating mod manifest files. +internal class ManifestHelper +{ + /// Load a mod manifest from a file path. + /// The file path to read. + /// The manifest, if loaded successfully. + /// A human-readable error indicating why the manifest couldn't be loaded, if applicable. + /// Returns whether the manifest was loaded successfully. + public static bool TryLoadManifest(string manifestPath, out IManifest manifest, out string error) + { + JsonHelper jsonHelper = new(); + + // check manifest exists + if (!File.Exists(manifestPath)) + { + error = "file doesn't exist"; + manifest = null; + return false; + } + + // get raw JSON + string manifestJson = File.ReadAllText(manifestPath); + + // parse JSON + try + { + manifest = jsonHelper.Deserialize(manifestJson); + } + catch (JsonReaderException ex) + { + // log the inner exception, otherwise the message will be generic + Exception exToShow = ex.InnerException ?? ex; + error = $"file has invalid JSON ({exToShow.Message})"; + manifest = null; + return false; + } + + // validate manifest fields + if (!ManifestHelper.TryValidateFields(manifest, out error)) + return false; + + // valid + error = null; + return true; + } + + /// + public static bool TryValidateFields(IManifest manifest, out string error) + { + return ManifestValidator.TryValidateFields(manifest, out error); + } +} From c6796cd54ddaf23c65205191a698d40848fb460f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 13 Apr 2025 19:12:49 -0400 Subject: [PATCH 4/6] add %ProjectVersion% token in manifest files --- docs/technical/mod-package-release-notes.md | 1 + docs/technical/mod-package.md | 25 +++++++++++++++++ src/SMAPI.ModBuildConfig/DeployModTask.cs | 19 +++++++++---- .../Framework/BundleFile.cs | 18 ++++++++++-- .../Framework/ContentPackFileManager.cs | 28 +++++++++---------- .../Framework/MainModFileManager.cs | 10 ++++--- .../Framework/ManifestHelper.cs | 20 ++++++++++--- src/SMAPI.ModBuildConfig/build/smapi.targets | 1 + 8 files changed, 90 insertions(+), 32 deletions(-) diff --git a/docs/technical/mod-package-release-notes.md b/docs/technical/mod-package-release-notes.md index d88b7e11..443e657e 100644 --- a/docs/technical/mod-package-release-notes.md +++ b/docs/technical/mod-package-release-notes.md @@ -2,6 +2,7 @@ ## Release notes ## Upcoming release +* Added `%ProjectVersion%` [token you can use in `manifest.json` files](mod-package.md#manifest-tokens). * Revamped [package documentation](mod-package.md). _This makes it easier to see the features at a glance, and collects the info for each feature into one place._ diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 398f6d83..31329f55 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -10,6 +10,7 @@ for SMAPI mods and related tools. The package is fully compatible with Linux, ma * [Install and debug your mod](#install-and-debug-your-mod) * [Release zip](#release-zip) * [Bundled content packs](#bundled-content-packs) + * [Manifest tokens](#manifest-tokens) * [Code warnings](#code-warnings) * [Unit tests](#unit-tests) * [Other features](#other-features) @@ -125,6 +126,30 @@ property | effect `IgnoreModFilePaths` | _(Optional)_ A list of file paths to ignore (relative to the content pack's directory); see `IgnoreModFilePaths` in the main settings. Default none. `IgnoreModFilePatterns` | _(Optional)_ A list of file regex patterns to ignore (relative to the content pack's directory); see `IgnoreModFilePatterns` in the main settings. Default none. +### Manifest tokens +It's best practices in .NET to set the code version in your `.csproj` project file. For example: +```xml + + 1.0.0 + +``` + +If you do, you can use `%ProjectVersion%` in your [`manifest.json`](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest). +This will be replaced with your project version automatically in your [game folder](#deploy) and [the release zip](#release-zip). + +For example: +```js +{ + "Name": "Your Mod Name", + "Author": "Your Name", + "Version": "%ProjectVersion%", + ... +} +``` + +This also works with [bundled content packs](#bundled-content-packs). When your mod consists of +multiple parts, this lets you keep their versions in sync automatically. + ### Code warnings The package runs code analysis on your mod and raises warnings for some common errors or pitfalls specific to Stardew Valley modding. diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 3f626178..e6f7ce22 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -30,6 +30,10 @@ public class DeployModTask : Task [Required] public string ModZipPath { get; set; } + /// The version number for the project assembly. + [Required] + public string ProjectVersion { get; set; } + /// The folder containing the project files. [Required] public string ProjectDir { get; set; } @@ -85,7 +89,7 @@ public class DeployModTask : Task // read & validate manifest string manifestPath = Path.Combine(this.ProjectDir, BundleFile.ManifestFileName); - if (!ManifestHelper.TryLoadManifest(manifestPath, out IManifest manifest, out string error)) + if (!ManifestHelper.TryLoadManifest(manifestPath, this.ProjectVersion, out IManifest manifest, out string overrideManifestJson, out string error)) { this.Log.LogError($"[mod build package] The mod's {BundleFile.ManifestFileName} is invalid: {error}"); return false; @@ -103,7 +107,7 @@ public class DeployModTask : Task var modPackages = new Dictionary { - [this.ModFolderName] = new MainModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) + [this.ModFolderName] = new MainModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, overrideManifestJson, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) }; if (this.ContentPacks != null) @@ -133,7 +137,7 @@ public class DeployModTask : Task this.Log.LogMessage(MessageImportance.High, $"[mod build package] Bundling content pack: {folderName} v{version} at {contentPath}."); modPackages.Add( folderName, - new ContentPackFileManager(this.ProjectDir, contentPath, version, ignoreFilePaths, ignoreFilePatterns, validateManifest) + new ContentPackFileManager(this.ProjectDir, contentPath, this.ProjectVersion, version, ignoreFilePaths, ignoreFilePatterns, validateManifest) ); } } @@ -274,13 +278,16 @@ public class DeployModTask : Task /// The folder path to create with the mod files. private void CreateModFolder(IDictionary modPackages, string outputPath) { - foreach (var mod in modPackages) + foreach (var pair in modPackages) { + string folderName = pair.Key; + IModFileManager fileManager = pair.Value; + string folderPath = modPackages.Count == 1 ? outputPath - : Path.Combine(outputPath, this.EscapeInvalidFilenameCharacters(mod.Key)); + : Path.Combine(outputPath, this.EscapeInvalidFilenameCharacters(folderName)); - foreach (BundleFile from in mod.Value.GetFiles()) + foreach (BundleFile from in fileManager.GetFiles()) from.CopyToFolder(folderPath); } } diff --git a/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs b/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs index a6c4eca0..fb9d0739 100644 --- a/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs +++ b/src/SMAPI.ModBuildConfig/Framework/BundleFile.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text; namespace StardewModdingAPI.ModBuildConfig.Framework; @@ -18,6 +19,8 @@ internal class BundleFile /// The file to copy from. public FileInfo File { get; } + /// If set, deploy this content instead of copying the original file. + public string OverrideContent { get; } /********* ** Public methods @@ -25,10 +28,12 @@ internal class BundleFile /// Construct an instance. /// The file's relative path within the mod. /// The file to copy from. - public BundleFile(string relativePath, FileInfo file) + /// If set, deploy this content instead of copying the original file. + public BundleFile(string relativePath, FileInfo file, string overrideContent = null) { this.RelativePath = relativePath; this.File = file; + this.OverrideContent = overrideContent; } /// Get whether this entry is for the mod's manifest.json file. @@ -44,14 +49,21 @@ internal class BundleFile string toPath = Path.Combine(folderPath, this.RelativePath); Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); - this.File.CopyTo(toPath, overwrite: true); + + if (this.OverrideContent != null) + System.IO.File.WriteAllText(toPath, this.OverrideContent, Encoding.UTF8); + else + this.File.CopyTo(toPath, overwrite: true); } /// Copy the file's contents into a stream. /// The stream into which to write the file's contents. public void CopyToStream(Stream stream) { - using FileStream fromStream = this.File.OpenRead(); + using Stream fromStream = this.OverrideContent != null + ? new MemoryStream(Encoding.UTF8.GetBytes(this.OverrideContent)) + : this.File.OpenRead(); + fromStream.CopyTo(stream); } diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs index 48c5a790..5c53bfa6 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs @@ -24,40 +24,38 @@ internal class ContentPackFileManager : IModFileManager /// Construct an instance. /// The folder containing the project files. /// The absolute or relative path to the content pack folder. + /// The version number for the project assembly. /// The mod version. /// The custom relative file paths provided by the user to ignore. /// Custom regex patterns matching files to ignore when deploying or zipping the mod. /// Whether to validate that the content pack's manifest is valid. /// The mod package isn't valid. - public ContentPackFileManager(string projectDir, string contentPackDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateManifest) + public ContentPackFileManager(string projectDir, string contentPackDir, string projectVersion, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateManifest) { // get folders DirectoryInfo projectDirInfo = new(Path.Combine(projectDir, contentPackDir)); if (!projectDirInfo.Exists) throw GetError($"that folder doesn't exist at {projectDirInfo.FullName}"); + // load manifest + string manifestPath = Path.Combine(contentPackDir, BundleFile.ManifestFileName); + if (!ManifestHelper.TryLoadManifest(manifestPath, projectVersion, out IManifest manifest, out string overrideManifestJson, out string error)) + throw GetError($"its {BundleFile.ManifestFileName} file is invalid: {error}"); + // collect files - BundleFile manifestEntry = null; foreach (FileInfo file in projectDirInfo.GetFiles("*", SearchOption.AllDirectories)) { string relativePath = PathUtilities.GetRelativePath(projectDirInfo.FullName, file.FullName); - if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns)) - { - BundleFile entry = new(relativePath, file); - this.Files.Add(entry); + if (this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns)) + continue; - if (manifestEntry is null && entry.IsModManifest()) - manifestEntry = entry; - } + this.Files.Add(BundleFile.IsModManifest(relativePath) + ? new BundleFile(relativePath, file, overrideManifestJson) + : new BundleFile(relativePath, file) + ); } - // load manifest - if (manifestEntry is null) - throw GetError($"it has no {BundleFile.ManifestFileName} file"); - if (!ManifestHelper.TryLoadManifest(manifestEntry.File.FullName, out IManifest manifest, out string error)) - throw GetError($"its {BundleFile.ManifestFileName} file is invalid: {error}"); - // validate manifest version if (validateManifest) { diff --git a/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs index f25832fa..6107013b 100644 --- a/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs @@ -66,9 +66,10 @@ internal class MainModFileManager : IModFileManager /// Custom regex patterns matching files to ignore when deploying or zipping the mod. /// The extra assembly types which should be bundled with the mod. /// The name (without extension or path) for the current mod's DLL. + /// If set, replace the mod's manifest.json file with this content. /// Whether to validate that required mod files like the manifest are present. /// The mod package isn't valid. - public MainModFileManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, bool validateRequiredModFiles) + public MainModFileManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, string overrideManifestJson, bool validateRequiredModFiles) { // validate paths if (!Directory.Exists(projectDir)) @@ -78,7 +79,7 @@ internal class MainModFileManager : IModFileManager // collect files BundleFile manifestEntry = null; - foreach (BundleFile entry in this.GetPossibleFiles(projectDir, targetDir)) + foreach (BundleFile entry in this.GetPossibleFiles(projectDir, targetDir, overrideManifestJson)) { if (!this.ShouldIgnore(entry.File, entry.RelativePath, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, modDllName)) { @@ -116,8 +117,9 @@ internal class MainModFileManager : IModFileManager /// Get all files to include in the mod folder, not accounting for ignore patterns. /// The folder containing the project files. /// The folder containing the build output. + /// If set, replace the mod's manifest.json file with this content. /// Returns tuples containing the relative path within the mod folder, and the file to copy to it. - private IEnumerable GetPossibleFiles(string projectDir, string targetDir) + private IEnumerable GetPossibleFiles(string projectDir, string targetDir, string overrideManifestJson) { // project manifest bool hasProjectManifest = false; @@ -125,7 +127,7 @@ internal class MainModFileManager : IModFileManager FileInfo manifest = new(Path.Combine(projectDir, BundleFile.ManifestFileName)); if (manifest.Exists) { - yield return new BundleFile(BundleFile.ManifestFileName, manifest); + yield return new BundleFile(BundleFile.ManifestFileName, manifest, overrideManifestJson); hasProjectManifest = true; } } diff --git a/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs b/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs index bc546ee1..45bba2ac 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ManifestHelper.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text.RegularExpressions; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Serialization; @@ -12,23 +13,34 @@ internal class ManifestHelper { /// Load a mod manifest from a file path. /// The file path to read. + /// The version number for the project assembly. /// The manifest, if loaded successfully. + /// The manifest JSON with tokens like %ProjectVersion% replaced, if loaded successfully different from the original file. /// A human-readable error indicating why the manifest couldn't be loaded, if applicable. /// Returns whether the manifest was loaded successfully. - public static bool TryLoadManifest(string manifestPath, out IManifest manifest, out string error) + public static bool TryLoadManifest(string manifestPath, string projectVersion, out IManifest manifest, out string overrideManifestJson, out string error) { JsonHelper jsonHelper = new(); // check manifest exists if (!File.Exists(manifestPath)) { - error = "file doesn't exist"; + error = "The mod's manifest.json file doesn't exist."; manifest = null; + overrideManifestJson = null; return false; } - // get raw JSON - string manifestJson = File.ReadAllText(manifestPath); + // get raw JSON & replace tokens + string manifestJson; + { + string rawJson = File.ReadAllText(manifestPath); + + manifestJson = Regex.Replace(rawJson, "%ProjectVersion%", projectVersion, RegexOptions.IgnoreCase); + overrideManifestJson = manifestJson != rawJson + ? manifestJson + : null; + } // parse JSON try diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 708dc3ad..c4091eea 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -88,6 +88,7 @@ ModDllName="$(TargetName)" ModFolderName="$(ModFolderName)" ModZipPath="$(ModZipPath)" + ProjectVersion="$(Version)" EnableModDeploy="$(EnableModDeploy)" EnableModZip="$(EnableModZip)" From 83e1f8470b924ccf2c51b19f62b4065424890b1b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 13 Apr 2025 19:12:49 -0400 Subject: [PATCH 5/6] fix broken links in mod build package's README when bundled into package --- docs/technical/mod-package.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 31329f55..84d473c5 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -1,4 +1,4 @@ -← [SMAPI](../README.md) +← [SMAPI](https://github.com/Pathoschild/SMAPI/blob/develop/docs/README.md) The **mod build package** is an open-source NuGet package which automates the MSBuild configuration for SMAPI mods and related tools. The package is fully compatible with Linux, macOS, and Windows. @@ -155,7 +155,7 @@ The package runs code analysis on your mod and raises warnings for some common e specific to Stardew Valley modding. For example: -> ![](screenshots/code-analyzer-example.png) +> ![screenshot](screenshots/code-analyzer-example.png) You can [hide the warnings](https://visualstudiomagazine.com/articles/2017/09/01/hide-compiler-warnings.aspx) if needed using the warning ID (shown under 'code' in Visual Studio's Error List). @@ -328,6 +328,6 @@ The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfi when you compile it. ## See also -* [Release notes](mod-package-release-notes.md) +* [Release notes](https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package-release-notes.md) [Harmony]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Harmony From 1eaf6374d490da0e977f389771c758c1d90537a4 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 13 Apr 2025 19:12:49 -0400 Subject: [PATCH 6/6] prepare for mod package release --- docs/technical/mod-package-release-notes.md | 4 +++- src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/technical/mod-package-release-notes.md b/docs/technical/mod-package-release-notes.md index 443e657e..71c74481 100644 --- a/docs/technical/mod-package-release-notes.md +++ b/docs/technical/mod-package-release-notes.md @@ -1,7 +1,9 @@ ← [mod build config](./mod-build-config.md) ## Release notes -## Upcoming release +## 4.4.0 +Released 13 April 2025 for SMAPI 4.1.0 or later. + * Added `%ProjectVersion%` [token you can use in `manifest.json` files](mod-package.md#manifest-tokens). * Revamped [package documentation](mod-package.md). _This makes it easier to see the features at a glance, and collects the info for each feature into one place._ diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index ac00b68f..5c503f7b 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -10,7 +10,7 @@ Pathoschild.Stardew.ModBuildConfig Build package for SMAPI mods - 4.3.2 + 4.4.0 Pathoschild Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 4.1.0 or later. mod-package.md