From 65992200f736152aa2805d50fe362c53e6114070 Mon Sep 17 00:00:00 2001 From: Abhi Agarwal Date: Sun, 24 Mar 2024 21:39:49 -0400 Subject: [PATCH 1/5] Add ability to add arbitrary content mods --- docs/technical/mod-package-release-notes.md | 3 + docs/technical/mod-package.md | 22 ++-- src/SMAPI.ModBuildConfig/DeployModTask.cs | 107 +++++++++------- ...{ModFileManager.cs => CSharpModManager.cs} | 12 +- .../Framework/ContentModManager.cs | 120 ++++++++++++++++++ .../Framework/IModFileManager.cs | 11 ++ src/SMAPI.ModBuildConfig/build/smapi.targets | 1 + 7 files changed, 218 insertions(+), 58 deletions(-) rename src/SMAPI.ModBuildConfig/Framework/{ModFileManager.cs => CSharpModManager.cs} (96%) create mode 100644 src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs create mode 100644 src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs diff --git a/docs/technical/mod-package-release-notes.md b/docs/technical/mod-package-release-notes.md index 9cf4356e..e8af6173 100644 --- a/docs/technical/mod-package-release-notes.md +++ b/docs/technical/mod-package-release-notes.md @@ -1,6 +1,9 @@ ← [mod build config](./mod-build-config.md) ## Release notes +## Upcoming release +* Added ability to include arbitrary content-based mods to output. + ### 4.2.0 Released 05 September 2024 for SMAPI 3.13.0 or later. diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index f1fda059..f77b352b 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -26,29 +26,35 @@ for SMAPI mods and related tools. The package is fully compatible with Linux, ma The package includes several features to simplify mod development (see [_configure_](#configure) to change how these work): -* **Detect game path:** +* **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)`. -* **Add assembly references:** +* **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. -* **Copy files into the `Mods` folder:** +* **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. -* **Create release zip:** +* **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. -* **Launch or debug mod:** +* **Copy non-built dependencies into the build output:** + Often, SMAPI mods have a component relying on other frameworks, such as Content Patcher, that + do not require a build. By declaring a list of dependents, the package will copy those files + into both the release zip and the `Mods` folder for testing. It creates a folder above them in the + hierarchy to avoid conflicts with other mod files and to group all related mods. + +* **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) @@ -56,11 +62,11 @@ change how these work): 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. -* **Preconfigure common settings:** +* **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. -* **Add code warnings:** +* **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. @@ -297,7 +303,7 @@ For example, this excludes all `.txt` and `.pdf` files, as well as the `assets/p ## Code warnings ### Overview -The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example: +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) diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 3508a6db..d59c8bbe 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -63,6 +63,9 @@ namespace StardewModdingAPI.ModBuildConfig /// A comma-separated list of values which indicate which extra DLLs to bundle. public string BundleExtraAssemblies { get; set; } + /// A list of directories containing mods that do not need to built (such as Content Patcher) to bundle. + public ITaskItem[] ContentItems { get; set; } + /********* ** Public methods @@ -120,28 +123,44 @@ namespace StardewModdingAPI.ModBuildConfig ExtraAssemblyTypes bundleAssemblyTypes = this.GetExtraAssembliesToBundleOption(); // parse ignore patterns - string[] ignoreFilePaths = this.GetCustomIgnoreFilePaths().ToArray(); - Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray(); + string[] ignoreFilePaths = this.GetCustomIgnoreFilePaths(this.IgnoreModFilePatterns).ToArray(); + Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); - // get mod info - ModFileManager package = new(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip); + var modPackages = new Dictionary { + { this.ModFolderName, new CSharpModManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) } + }; + if (this.ContentItems != null) + { + foreach (ITaskItem item in this.ContentItems.Where(p => p.GetMetadata("Generator") == "ModBuildConfig")) + { + string contentPath = item.GetMetadata("Include") ?? throw new UserErrorException("Content Mod does not have the 'Include' attribute."); + string contentName = item.GetMetadata("Link") ?? Path.GetDirectoryName(contentPath); + + ignoreFilePaths = this.GetCustomIgnoreFilePaths(item.GetMetadata("IgnoreModFilePatterns")).ToArray(); + ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); + modPackages.Add( + contentName, + new ContentPatcherModManager(contentPath, ignoreFilePaths, ignoreFilePatterns, bool.Parse(item.GetMetadata("Validate") ?? "true")) + ); + } + } // deploy mod files if (this.EnableModDeploy) { - string outputPath = Path.Combine(this.GameModsDir, this.EscapeInvalidFilenameCharacters(this.ModFolderName)); + string outputPath = Path.Combine(this.GameModsDir, EscapeInvalidFilenameCharacters(this.ModFolderName)); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Copying the mod files to {outputPath}..."); - this.CreateModFolder(package.GetFiles(), outputPath); + this.CreateModFolder(modPackages, outputPath); } // create release zip if (this.EnableModZip) { - string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); + string zipName = EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); string zipPath = Path.Combine(this.ModZipPath, zipName); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); - this.CreateReleaseZip(package.GetFiles(), this.ModFolderName, zipPath); + this.CreateReleaseZip(modPackages, zipPath); } return true; @@ -208,12 +227,13 @@ namespace StardewModdingAPI.ModBuildConfig } /// Get the custom ignore patterns provided by the user. - private IEnumerable GetCustomIgnorePatterns() + /// The comma-separated regex patterns. + private IEnumerable GetCustomIgnorePatterns(string patterns) { - if (string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) + if (string.IsNullOrWhiteSpace(patterns)) yield break; - foreach (string raw in this.IgnoreModFilePatterns.Split(',')) + foreach (string raw in patterns.Split(',')) { Regex regex; try @@ -222,7 +242,7 @@ namespace StardewModdingAPI.ModBuildConfig } catch (Exception ex) { - this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.IgnoreModFilePatterns)}> pattern {raw}:\n{ex}"); + this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(patterns)}> pattern {raw}:\n{ex}"); continue; } @@ -231,12 +251,13 @@ namespace StardewModdingAPI.ModBuildConfig } /// Get the custom relative file paths provided by the user to ignore. - private IEnumerable GetCustomIgnoreFilePaths() + /// The comma-separated file paths. + private IEnumerable GetCustomIgnoreFilePaths(string pattern) { - if (string.IsNullOrWhiteSpace(this.IgnoreModFilePaths)) + if (string.IsNullOrWhiteSpace(pattern)) yield break; - foreach (string raw in this.IgnoreModFilePaths.Split(',')) + foreach (string raw in pattern.Split(',')) { string path; try @@ -245,7 +266,7 @@ namespace StardewModdingAPI.ModBuildConfig } catch (Exception ex) { - this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(this.IgnoreModFilePaths)}> path {raw}:\n{ex}"); + this.Log.LogWarning($"[mod build package] Ignored invalid <{nameof(pattern)}> path {raw}:\n{ex}"); continue; } @@ -254,54 +275,52 @@ namespace StardewModdingAPI.ModBuildConfig } /// Copy the mod files into the game's mod folder. - /// The files to include. - /// The folder path to create with the mod files. - private void CreateModFolder(IDictionary files, string modFolderPath) + /// The mods to include. + /// The folder path to create with the mod files. + private void CreateModFolder(IDictionary modPackages, string outputPath) { - foreach (var entry in files) - { - string fromPath = entry.Value.FullName; - string toPath = Path.Combine(modFolderPath, entry.Key); + foreach (var mod in modPackages) { + string modSpecificFolder = modPackages.Count == 1 ? outputPath : EscapeInvalidFilenameCharacters(Path.Combine(outputPath, mod.Key)); + foreach (var file in mod.Value.GetFiles()) + { + string fromPath = file.Value.FullName; + string toPath = Path.Combine(modSpecificFolder, file.Key); - Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); - File.Copy(fromPath, toPath, overwrite: true); + File.Copy(fromPath, toPath, overwrite: true); + } } + } /// Create a release zip in the recommended format for uploading to mod sites. - /// The files to include. - /// The name of the mod. + /// The mods to include. /// The absolute path to the zip file to create. - private void CreateReleaseZip(IDictionary files, string modName, string zipPath) + private void CreateReleaseZip(IDictionary modPackages, string zipPath) { - // get folder name within zip - string folderName = this.EscapeInvalidFilenameCharacters(modName); - // create zip file Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); using ZipArchive archive = new(zipStream, ZipArchiveMode.Create); - foreach (var fileEntry in files) - { - string relativePath = fileEntry.Key; - FileInfo file = fileEntry.Value; + foreach (var mod in modPackages) { + string modFolder = EscapeInvalidFilenameCharacters(mod.Key); + foreach (var file in mod.Value.GetFiles()) + { + string relativePath = file.Key; + FileInfo fileInfo = file.Value; - // get file info - string filePath = file.FullName; - string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); - - // add to zip - using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); - using Stream fileStreamInZip = archive.CreateEntry(entryName).Open(); - fileStream.CopyTo(fileStreamInZip); + using Stream fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); + using Stream fileStreamInZip = archive.CreateEntry(modFolder).Open(); + fileStream.CopyTo(fileStreamInZip); + } } } /// Get a copy of a filename with all invalid filename characters substituted. /// The filename. - private string EscapeInvalidFilenameCharacters(string name) + private static string EscapeInvalidFilenameCharacters(string name) { foreach (char invalidChar in Path.GetInvalidFileNameChars()) name = name.Replace(invalidChar, '.'); diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs similarity index 96% rename from src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs rename to src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs index ef5b0639..66afcf14 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs @@ -7,8 +7,8 @@ using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework { - /// Manages the files that are part of a mod package. - internal class ModFileManager + /// Manages the files that are part of a C# mod. + internal class CSharpModManager : IModFileManager { /********* ** Fields @@ -71,16 +71,16 @@ namespace StardewModdingAPI.ModBuildConfig.Framework /// The name (without extension or path) for the current mod's DLL. /// Whether to validate that required mod files like the manifest are present. /// The mod package isn't valid. - public ModFileManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, bool validateRequiredModFiles) + public CSharpModManager(string projectDir, string targetDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, ExtraAssemblyTypes bundleAssemblyTypes, string modDllName, bool validateRequiredModFiles) { - this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); - // validate paths if (!Directory.Exists(projectDir)) throw new UserErrorException("Could not create mod package because the project folder wasn't found."); if (!Directory.Exists(targetDir)) throw new UserErrorException("Could not create mod package because no build output was found."); + this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); + // collect files foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir)) { @@ -105,7 +105,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework } } - /// Get the files in the mod package. + /// public IDictionary GetFiles() { return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs new file mode 100644 index 00000000..04028e90 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Framework; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.ModBuildConfig.Framework +{ + /// Manages the files that are part of a content-based mod. + internal class ContentPatcherModManager : IModFileManager + { + private readonly string ManifestFileName = "manifest.json"; + + private readonly string ContentFileName = "content.json"; + + /// The files that are part of the package. + private readonly IDictionary Files; + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The folder containing the project files. + /// 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 required mod files like the manifest are present. + /// The mod package isn't valid. + public ContentPatcherModManager(string projectDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateRequiredModFiles) + { + DirectoryInfo projectDirInfo = new DirectoryInfo(projectDir); + if(!projectDirInfo.Exists) + throw new UserErrorException($"The project directory '{projectDir}' for a Content Mod does not exist."); + + this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // collect files + foreach (Tuple entry in projectDirInfo.GetFiles("*", SearchOption.AllDirectories).Select(p => Tuple.Create(PathUtilities.GetRelativePath(projectDir, p.Name), p))) + { + string relativePath = entry.Item1; + FileInfo file = entry.Item2; + + if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns)) + this.Files[relativePath] = file; + } + + // check for required files + if (validateRequiredModFiles) + { + // manifest + FileInfo manifestFile = this.Files[this.ManifestFileName]; + Manifest manifest; + if (manifestFile == null) + throw new UserErrorException($"Could not add Content Mod '{projectDir}' because no {this.ManifestFileName} was found in the project"); + try + { + new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest rawManifest); + manifest = rawManifest; + } + catch (JsonReaderException ex) + { + Exception exToShow = ex.InnerException ?? ex; + throw new UserErrorException($"Could not add Content Mod '{projectDir}' because {this.ManifestFileName} is not valid JSON: {exToShow.Message}"); + } + // validate manifest fields + if (!ManifestValidator.TryValidateFields(manifest, out string error)) + { + throw new UserErrorException($"Could not add Content Mod '{projectDir}' because mod's {this.ManifestFileName} did not validate: {error}"); + } + if (!this.Files.ContainsKey(this.ContentFileName)) + throw new UserErrorException($"Could not add Content Mod because no {this.ContentFileName} was found in the project"); + } + } + + /// + public IDictionary GetFiles() + { + return new Dictionary(this.Files, StringComparer.OrdinalIgnoreCase); + } + + /// Get whether a content file should be ignored. + /// The file to check. + /// The file's relative path in the package. + /// The custom relative file paths provided by the user to ignore. + /// Custom regex patterns matching files to ignore when deploying or zipping the mod. + private bool ShouldIgnore(FileInfo file, string relativePath, string[] ignoreFilePaths, Regex[] ignoreFilePatterns) + { + // apply custom patterns + if (ignoreFilePaths.Any(p => p == relativePath) || ignoreFilePatterns.Any(p => p.IsMatch(relativePath))) + return true; + + { + bool shouldIgnore = + // release zips + this.EqualsInvariant(file.Extension, ".zip") + + // OS metadata files + || this.EqualsInvariant(file.Name, ".DS_Store") + || this.EqualsInvariant(file.Name, "Thumbs.db"); + if (shouldIgnore) + return true; + } + return false; + } + + // Get whether a string is equal to another case-insensitively. + /// The string value. + /// The string to compare with. + private bool EqualsInvariant(string str, string other) + { + if (str == null) + return other == null; + return str.Equals(other, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs new file mode 100644 index 00000000..c7e47c94 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.IO; + +namespace StardewModdingAPI.ModBuildConfig.Framework +{ + public interface IModFileManager + { + /// Get the files in the mod package. + public IDictionary GetFiles(); + } +} diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 41b5bc86..9876fe73 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -99,6 +99,7 @@ IgnoreModFilePaths="$(IgnoreModFilePaths)" BundleExtraAssemblies="$(BundleExtraAssemblies)" + AdditionalModsToAdd="@(Content)" /> From cf0039dd1a6cb0697c297ef8b68d3335bcfed074 Mon Sep 17 00:00:00 2001 From: Abhi Agarwal Date: Mon, 25 Mar 2024 16:42:35 -0400 Subject: [PATCH 2/5] Add docs and version checks --- docs/technical/mod-package.md | 81 ++++++++++++++++++- src/SMAPI.ModBuildConfig/DeployModTask.cs | 9 ++- .../Framework/ContentModManager.cs | 7 +- src/SMAPI.ModBuildConfig/build/smapi.targets | 2 +- 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index f77b352b..3b1c3bc6 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -48,11 +48,10 @@ change how these work): format recommended for uploading to mod sites like Nexus Mods. This includes the same files as the previous feature. -* **Copy non-built dependencies into the build output:** +* **Copy Content-based dependencies into the build output:** Often, SMAPI mods have a component relying on other frameworks, such as Content Patcher, that do not require a build. By declaring a list of dependents, the package will copy those files - into both the release zip and the `Mods` folder for testing. It creates a folder above them in the - hierarchy to avoid conflicts with other mod files and to group all related mods. + into both the release zip and the `Mods` folder for testing. * **Launch or debug mod:** On Windows only, the package configures Visual Studio so you can launch the game and attach a @@ -301,6 +300,82 @@ For example, this excludes all `.txt` and `.pdf` files, as well as the `assets/p +### Adding Content-based mods + +Additional mods that don't require a build step, such as those modifying content via Content Patcher, can be added by specifying a `Content` resource in `ItemGroup`: + +```xml + + + +``` + +This mod will automatically get included in both the release zip and the `Mods` folder for testing. The package will also automatically create a folder to group all the related mods together under one place, including the release zip. + +#### Available properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
propertyeffect
Include + +The path to the included mod. This can be a relative path to the project or an absolute path. This is required. + +
Generator + +This must be set to `ModBuildConfig` to be recognized by the package. This is required. + +
Version + +Version of the mod. The package will validate that the included mod's manifest version matches. This is required. + +
Link + +An optional name to rename the mod in the release zip and `Mods` folder. If not set, the directory from `Include` will be used. + +
Validate + +Whether to validate that the included mod is a valid Content Patcher mod. This checks if `manifest.json` and `content.json` are present and validates the former. It also disables `Version` checks if `false`. This is optional and defaults to `true`. + +
IgnoreModFilePaths + +A list of file paths to ignore. This is done relative to the Content Patcher mod's directory. By default, all files will be copied. This is optional. Same logic as the `IgnoreModFilePaths` in `ProjItems`. + +
IgnoreModFilePatterns + +A list of file regex patterns to ignore. This is done relative to the Content Patcher mod's directory. This is optional. Same logic as the `IgnoreModFilePaths` in `ProjItems`. + +
+ ## Code warnings ### Overview The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example: diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index d59c8bbe..c2d3c53f 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -64,7 +64,7 @@ namespace StardewModdingAPI.ModBuildConfig public string BundleExtraAssemblies { get; set; } /// A list of directories containing mods that do not need to built (such as Content Patcher) to bundle. - public ITaskItem[] ContentItems { get; set; } + public ITaskItem[] ContentMods { get; set; } /********* @@ -130,18 +130,19 @@ namespace StardewModdingAPI.ModBuildConfig { this.ModFolderName, new CSharpModManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) } }; - if (this.ContentItems != null) + if (this.ContentMods != null) { - foreach (ITaskItem item in this.ContentItems.Where(p => p.GetMetadata("Generator") == "ModBuildConfig")) + foreach (ITaskItem item in this.ContentMods.Where(p => p.GetMetadata("Generator") == "ModBuildConfig")) { string contentPath = item.GetMetadata("Include") ?? throw new UserErrorException("Content Mod does not have the 'Include' attribute."); string contentName = item.GetMetadata("Link") ?? Path.GetDirectoryName(contentPath); + string contentVersion = item.GetMetadata("Version"); ignoreFilePaths = this.GetCustomIgnoreFilePaths(item.GetMetadata("IgnoreModFilePatterns")).ToArray(); ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); modPackages.Add( contentName, - new ContentPatcherModManager(contentPath, ignoreFilePaths, ignoreFilePatterns, bool.Parse(item.GetMetadata("Validate") ?? "true")) + new ContentPatcherModManager(contentPath, contentVersion, ignoreFilePaths, ignoreFilePatterns, bool.Parse(item.GetMetadata("Validate") ?? "true")) ); } } diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs index 04028e90..3d3b7bf8 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs @@ -26,11 +26,12 @@ namespace StardewModdingAPI.ModBuildConfig.Framework *********/ /// Construct an instance. /// The folder containing the project files. + /// 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 required mod files like the manifest are present. /// The mod package isn't valid. - public ContentPatcherModManager(string projectDir, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateRequiredModFiles) + public ContentPatcherModManager(string projectDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateRequiredModFiles) { DirectoryInfo projectDirInfo = new DirectoryInfo(projectDir); if(!projectDirInfo.Exists) @@ -71,6 +72,10 @@ namespace StardewModdingAPI.ModBuildConfig.Framework { throw new UserErrorException($"Could not add Content Mod '{projectDir}' because mod's {this.ManifestFileName} did not validate: {error}"); } + if (version == null) + throw new UserErrorException($"Could not add Content Mod '{projectDir}' because no version was provided"); + if(manifest.Version.ToString().CompareTo(version) != 0) + throw new UserErrorException($"Could not add Content Mod '{projectDir}' because the version in the manifest.json file does not match the version {version} provided."); if (!this.Files.ContainsKey(this.ContentFileName)) throw new UserErrorException($"Could not add Content Mod because no {this.ContentFileName} was found in the project"); } diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 9876fe73..b94589f4 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -99,7 +99,7 @@ IgnoreModFilePaths="$(IgnoreModFilePaths)" BundleExtraAssemblies="$(BundleExtraAssemblies)" - AdditionalModsToAdd="@(Content)" + ContentMods="@(Content)" /> From c420b8a0902a5c388dd8d6598688d897ab8a9ea6 Mon Sep 17 00:00:00 2001 From: Abhi Agarwal Date: Mon, 25 Mar 2024 18:37:30 -0400 Subject: [PATCH 3/5] Actually make it work --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 18 +++++++++++++----- .../Framework/ContentModManager.cs | 10 +++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index c2d3c53f..dd19edd8 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -134,15 +134,20 @@ namespace StardewModdingAPI.ModBuildConfig { foreach (ITaskItem item in this.ContentMods.Where(p => p.GetMetadata("Generator") == "ModBuildConfig")) { - string contentPath = item.GetMetadata("Include") ?? throw new UserErrorException("Content Mod does not have the 'Include' attribute."); - string contentName = item.GetMetadata("Link") ?? Path.GetDirectoryName(contentPath); + string contentPath = item.ItemSpec ?? throw new UserErrorException("Content Mod does not have the 'Include' attribute."); + string contentName = item.GetMetadata("Link"); string contentVersion = item.GetMetadata("Version"); + this.Log.LogMessage(MessageImportance.High, $"[mod build package] Found content mod: {contentName} v{contentVersion} at {contentPath}."); + ignoreFilePaths = this.GetCustomIgnoreFilePaths(item.GetMetadata("IgnoreModFilePatterns")).ToArray(); ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); + + string validateString = item.GetMetadata("Validate"); + bool validate = string.IsNullOrEmpty(validateString) || bool.Parse(validateString); modPackages.Add( contentName, - new ContentPatcherModManager(contentPath, contentVersion, ignoreFilePaths, ignoreFilePatterns, bool.Parse(item.GetMetadata("Validate") ?? "true")) + new ContentPatcherModManager(contentPath, contentVersion, ignoreFilePaths, ignoreFilePatterns, validate) ); } } @@ -191,6 +196,9 @@ namespace StardewModdingAPI.ModBuildConfig if (property.Name == nameof(this.IgnoreModFilePatterns) && string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) continue; + if (property.Name == nameof(this.ContentMods)) + continue; + string name = property.Name; string value = property.GetValue(this)?.ToString(); @@ -281,7 +289,7 @@ namespace StardewModdingAPI.ModBuildConfig private void CreateModFolder(IDictionary modPackages, string outputPath) { foreach (var mod in modPackages) { - string modSpecificFolder = modPackages.Count == 1 ? outputPath : EscapeInvalidFilenameCharacters(Path.Combine(outputPath, mod.Key)); + string modSpecificFolder = modPackages.Count == 1 ? outputPath : Path.Combine(outputPath, EscapeInvalidFilenameCharacters(mod.Key)); foreach (var file in mod.Value.GetFiles()) { string fromPath = file.Value.FullName; @@ -313,7 +321,7 @@ namespace StardewModdingAPI.ModBuildConfig FileInfo fileInfo = file.Value; using Stream fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); - using Stream fileStreamInZip = archive.CreateEntry(modFolder).Open(); + using Stream fileStreamInZip = archive.CreateEntry($"{modFolder}/{relativePath}").Open(); fileStream.CopyTo(fileStreamInZip); } } diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs index 3d3b7bf8..a8c7683a 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs @@ -16,8 +16,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework { private readonly string ManifestFileName = "manifest.json"; - private readonly string ContentFileName = "content.json"; - /// The files that are part of the package. private readonly IDictionary Files; @@ -40,10 +38,10 @@ namespace StardewModdingAPI.ModBuildConfig.Framework this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); // collect files - foreach (Tuple entry in projectDirInfo.GetFiles("*", SearchOption.AllDirectories).Select(p => Tuple.Create(PathUtilities.GetRelativePath(projectDir, p.Name), p))) + foreach (FileInfo entry in projectDirInfo.GetFiles("*", SearchOption.AllDirectories)) { - string relativePath = entry.Item1; - FileInfo file = entry.Item2; + string relativePath = PathUtilities.GetRelativePath(projectDirInfo.FullName, entry.FullName); + FileInfo file = entry; if (!this.ShouldIgnore(file, relativePath, ignoreFilePaths, ignoreFilePatterns)) this.Files[relativePath] = file; @@ -76,8 +74,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework throw new UserErrorException($"Could not add Content Mod '{projectDir}' because no version was provided"); if(manifest.Version.ToString().CompareTo(version) != 0) throw new UserErrorException($"Could not add Content Mod '{projectDir}' because the version in the manifest.json file does not match the version {version} provided."); - if (!this.Files.ContainsKey(this.ContentFileName)) - throw new UserErrorException($"Could not add Content Mod because no {this.ContentFileName} was found in the project"); } } From 1effd1bc9d3a4a7b5e00c043f5f026313ca96a71 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 14 Sep 2024 15:13:04 -0400 Subject: [PATCH 4/5] minor refactoring This commit... - simplifies usage with a new group; - fixes content pack folder name not defaulting to Include path; - fixes release zip not creating a parent folder when content packs are bundled; - fixes content pack manager's relative path resolution; - fixes implications that we only support Content Patcher packs; - and rewrites the docs a bit. --- docs/technical/mod-package-release-notes.md | 2 +- docs/technical/mod-package.md | 107 +++++------------- src/SMAPI.ModBuildConfig/DeployModTask.cs | 69 +++++++---- .../Framework/CSharpModManager.cs | 6 +- .../Framework/ContentModManager.cs | 78 +++++++------ .../Framework/IModFileManager.cs | 1 + src/SMAPI.ModBuildConfig/build/smapi.targets | 2 +- 7 files changed, 120 insertions(+), 145 deletions(-) diff --git a/docs/technical/mod-package-release-notes.md b/docs/technical/mod-package-release-notes.md index e8af6173..16f48cd7 100644 --- a/docs/technical/mod-package-release-notes.md +++ b/docs/technical/mod-package-release-notes.md @@ -2,7 +2,7 @@ ## Release notes ## Upcoming release -* Added ability to include arbitrary content-based mods to output. +* You can now [bundle content packs with your mod](mod-package.md#bundle-content-packs) (thanks to abhiaagarwal!). ### 4.2.0 Released 05 September 2024 for SMAPI 3.13.0 or later. diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md index 3b1c3bc6..6a820b67 100644 --- a/docs/technical/mod-package.md +++ b/docs/technical/mod-package.md @@ -7,6 +7,9 @@ for SMAPI mods and related tools. The package is fully compatible with Linux, ma * [Use](#use) * [Features](#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) @@ -26,34 +29,29 @@ for SMAPI mods and related tools. The package is fully compatible with Linux, ma The package includes several features to simplify mod development (see [_configure_](#configure) to change how these work): -* **Detect game path:** +* **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)`. -* **Add assembly references:** +* **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. -* **Copy files into the `Mods` folder:** +* **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. -* **Create release zip:** +* **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. -* **Copy Content-based dependencies into the build output:** - Often, SMAPI mods have a component relying on other frameworks, such as Content Patcher, that - do not require a build. By declaring a list of dependents, the package will copy those files - into both the release zip and the `Mods` folder for testing. - -* **Launch or debug mod:** +* **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) @@ -61,11 +59,11 @@ change how these work): 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. -* **Preconfigure common settings:** +* **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. -* **Add code warnings:** +* **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. @@ -300,85 +298,34 @@ For example, this excludes all `.txt` and `.pdf` files, as well as the `assets/p -### Adding Content-based mods +## 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. -Additional mods that don't require a build step, such as those modifying content via Content Patcher, can be added by specifying a `Content` resource in `ItemGroup`: +To do that, add an `ItemGroup` with a `ContentPacks` line for each content pack you want to include: ```xml - + + ``` -This mod will automatically get included in both the release zip and the `Mods` folder for testing. The package will also automatically create a folder to group all the related mods together under one place, including the release zip. +You can use these properties for each line: -#### Available properties - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
propertyeffect
Include - -The path to the included mod. This can be a relative path to the project or an absolute path. This is required. - -
Generator - -This must be set to `ModBuildConfig` to be recognized by the package. This is required. - -
Version - -Version of the mod. The package will validate that the included mod's manifest version matches. This is required. - -
Link - -An optional name to rename the mod in the release zip and `Mods` folder. If not set, the directory from `Include` will be used. - -
Validate - -Whether to validate that the included mod is a valid Content Patcher mod. This checks if `manifest.json` and `content.json` are present and validates the former. It also disables `Version` checks if `false`. This is optional and defaults to `true`. - -
IgnoreModFilePaths - -A list of file paths to ignore. This is done relative to the Content Patcher mod's directory. By default, all files will be copied. This is optional. Same logic as the `IgnoreModFilePaths` in `ProjItems`. - -
IgnoreModFilePatterns - -A list of file regex patterns to ignore. This is done relative to the Content Patcher mod's directory. This is optional. Same logic as the `IgnoreModFilePaths` in `ProjItems`. - -
+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: +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) diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index dd19edd8..77bb8658 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -63,8 +63,8 @@ namespace StardewModdingAPI.ModBuildConfig /// A comma-separated list of values which indicate which extra DLLs to bundle. public string BundleExtraAssemblies { get; set; } - /// A list of directories containing mods that do not need to built (such as Content Patcher) to bundle. - public ITaskItem[] ContentMods { get; set; } + /// A list of content pack folders to bundle with the mod. + public ITaskItem[] ContentPacks { get; set; } /********* @@ -126,35 +126,46 @@ namespace StardewModdingAPI.ModBuildConfig string[] ignoreFilePaths = this.GetCustomIgnoreFilePaths(this.IgnoreModFilePatterns).ToArray(); Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); - var modPackages = new Dictionary { - { this.ModFolderName, new CSharpModManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) } + var modPackages = new Dictionary + { + [this.ModFolderName] = new CSharpModManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) }; - if (this.ContentMods != null) + if (this.ContentPacks != null) { - foreach (ITaskItem item in this.ContentMods.Where(p => p.GetMetadata("Generator") == "ModBuildConfig")) + foreach (ITaskItem item in this.ContentPacks) { - string contentPath = item.ItemSpec ?? throw new UserErrorException("Content Mod does not have the 'Include' attribute."); - string contentName = item.GetMetadata("Link"); - string contentVersion = item.GetMetadata("Version"); + // get content folder path + string contentPath = item.ItemSpec; + if (string.IsNullOrWhiteSpace(contentPath)) + throw new UserErrorException("Content pack doesn't have the required 'Include' attribute."); - this.Log.LogMessage(MessageImportance.High, $"[mod build package] Found content mod: {contentName} v{contentVersion} at {contentPath}."); + // get folder name + string folderName = item.GetMetadata("FolderName"); + if (string.IsNullOrWhiteSpace(folderName)) + folderName = Path.GetFileName(contentPath); + // get version + string version = item.GetMetadata("Version"); + + // get options ignoreFilePaths = this.GetCustomIgnoreFilePaths(item.GetMetadata("IgnoreModFilePatterns")).ToArray(); ignoreFilePatterns = this.GetCustomIgnorePatterns(this.IgnoreModFilePaths).ToArray(); + string rawValidateManifest = item.GetMetadata("ValidateManifest"); + bool validateManifest = string.IsNullOrEmpty(rawValidateManifest) || bool.Parse(rawValidateManifest); - string validateString = item.GetMetadata("Validate"); - bool validate = string.IsNullOrEmpty(validateString) || bool.Parse(validateString); + // apply + this.Log.LogMessage(MessageImportance.High, $"[mod build package] Bundling content pack: {folderName} v{version} at {contentPath}."); modPackages.Add( - contentName, - new ContentPatcherModManager(contentPath, contentVersion, ignoreFilePaths, ignoreFilePatterns, validate) + folderName, + new ContentPatcherModManager(this.ProjectDir, contentPath, version, ignoreFilePaths, ignoreFilePatterns, validateManifest) ); } } // deploy mod files if (this.EnableModDeploy) { - string outputPath = Path.Combine(this.GameModsDir, EscapeInvalidFilenameCharacters(this.ModFolderName)); + string outputPath = Path.Combine(this.GameModsDir, this.EscapeInvalidFilenameCharacters(this.ModFolderName)); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Copying the mod files to {outputPath}..."); this.CreateModFolder(modPackages, outputPath); } @@ -162,7 +173,7 @@ namespace StardewModdingAPI.ModBuildConfig // create release zip if (this.EnableModZip) { - string zipName = EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); + string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModFolderName} {manifest.Version}.zip"); string zipPath = Path.Combine(this.ModZipPath, zipName); this.Log.LogMessage(MessageImportance.High, $"[mod build package] Generating the release zip at {zipPath}..."); @@ -196,7 +207,7 @@ namespace StardewModdingAPI.ModBuildConfig if (property.Name == nameof(this.IgnoreModFilePatterns) && string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns)) continue; - if (property.Name == nameof(this.ContentMods)) + if (property.Name == nameof(this.ContentPacks)) continue; string name = property.Name; @@ -288,19 +299,22 @@ namespace StardewModdingAPI.ModBuildConfig /// The folder path to create with the mod files. private void CreateModFolder(IDictionary modPackages, string outputPath) { - foreach (var mod in modPackages) { - string modSpecificFolder = modPackages.Count == 1 ? outputPath : Path.Combine(outputPath, EscapeInvalidFilenameCharacters(mod.Key)); + foreach (var mod in modPackages) + { + string relativePath = 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(modSpecificFolder, file.Key); + string toPath = Path.Combine(relativePath, file.Key); Directory.CreateDirectory(Path.GetDirectoryName(toPath)!); File.Copy(fromPath, toPath, overwrite: true); } } - } /// Create a release zip in the recommended format for uploading to mod sites. @@ -313,15 +327,20 @@ namespace StardewModdingAPI.ModBuildConfig using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); using ZipArchive archive = new(zipStream, ZipArchiveMode.Create); - foreach (var mod in modPackages) { - string modFolder = EscapeInvalidFilenameCharacters(mod.Key); + foreach (var mod in modPackages) + { + string modFolder = this.EscapeInvalidFilenameCharacters(mod.Key); foreach (var file in mod.Value.GetFiles()) { string relativePath = file.Key; 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($"{modFolder}/{relativePath}").Open(); + using Stream fileStreamInZip = archive.CreateEntry(archivePath).Open(); fileStream.CopyTo(fileStreamInZip); } } @@ -329,7 +348,7 @@ namespace StardewModdingAPI.ModBuildConfig /// Get a copy of a filename with all invalid filename characters substituted. /// The filename. - private static string EscapeInvalidFilenameCharacters(string name) + private string EscapeInvalidFilenameCharacters(string name) { foreach (char invalidChar in Path.GetInvalidFileNameChars()) name = name.Replace(invalidChar, '.'); diff --git a/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs b/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs index 66afcf14..1249241d 100644 --- a/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs @@ -7,7 +7,7 @@ using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework { - /// Manages the files that are part of a C# mod. + /// Manages the files that are part of the main C# mod. internal class CSharpModManager : IModFileManager { /********* @@ -17,7 +17,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework private readonly string ManifestFileName = "manifest.json"; /// The files that are part of the package. - private readonly IDictionary Files; + private readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); /// The file extensions used by assembly files. private readonly ISet AssemblyFileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -79,8 +79,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework if (!Directory.Exists(targetDir)) throw new UserErrorException("Could not create mod package because no build output was found."); - this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); - // collect files foreach (Tuple entry in this.GetPossibleFiles(projectDir, targetDir)) { diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs index a8c7683a..44bfcfa4 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs @@ -4,6 +4,7 @@ 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; @@ -11,31 +12,36 @@ using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework { - /// Manages the files that are part of a content-based mod. + /// Manages the files that are part of a bundled content pack. internal class ContentPatcherModManager : IModFileManager { + /********* + ** Fields + *********/ + /// The name of the manifest file. private readonly string ManifestFileName = "manifest.json"; /// The files that are part of the package. - private readonly IDictionary Files; + private readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); + /********* ** Public methods *********/ /// Construct an instance. /// The folder containing the project files. + /// The absolute or relative path to the content pack folder. /// 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 required mod files like the manifest are present. + /// Whether to validate that the content pack's manifest is valid. /// The mod package isn't valid. - public ContentPatcherModManager(string projectDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateRequiredModFiles) + public ContentPatcherModManager(string projectDir, string contentPackDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateManifest) { - DirectoryInfo projectDirInfo = new DirectoryInfo(projectDir); - if(!projectDirInfo.Exists) - throw new UserErrorException($"The project directory '{projectDir}' for a Content Mod does not exist."); - - this.Files = new Dictionary(StringComparer.OrdinalIgnoreCase); + // get folders + DirectoryInfo projectDirInfo = new DirectoryInfo(Path.Combine(projectDir, contentPackDir)); + if (!projectDirInfo.Exists) + throw GetError($"that folder doesn't exist at {projectDirInfo.FullName}"); // collect files foreach (FileInfo entry in projectDirInfo.GetFiles("*", SearchOption.AllDirectories)) @@ -47,14 +53,15 @@ namespace StardewModdingAPI.ModBuildConfig.Framework this.Files[relativePath] = file; } - // check for required files - if (validateRequiredModFiles) + // validate manifest + if (validateManifest) { - // manifest - FileInfo manifestFile = this.Files[this.ManifestFileName]; + // get manifest file + if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile)) + throw GetError($"it has no {this.ManifestFileName} file"); + + // parse file Manifest manifest; - if (manifestFile == null) - throw new UserErrorException($"Could not add Content Mod '{projectDir}' because no {this.ManifestFileName} was found in the project"); try { new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest rawManifest); @@ -62,18 +69,25 @@ namespace StardewModdingAPI.ModBuildConfig.Framework } catch (JsonReaderException ex) { - Exception exToShow = ex.InnerException ?? ex; - throw new UserErrorException($"Could not add Content Mod '{projectDir}' because {this.ManifestFileName} is not valid JSON: {exToShow.Message}"); + throw GetError($"its {this.ManifestFileName} file isn't valid JSON: {ex.InnerException?.Message ?? ex.Message}"); } + // validate manifest fields if (!ManifestValidator.TryValidateFields(manifest, out string error)) - { - throw new UserErrorException($"Could not add Content Mod '{projectDir}' because mod's {this.ManifestFileName} did not validate: {error}"); - } + throw GetError($"its {this.ManifestFileName} file is invalid: {error}"); + + // validate version if (version == null) - throw new UserErrorException($"Could not add Content Mod '{projectDir}' because no version was provided"); - if(manifest.Version.ToString().CompareTo(version) != 0) - throw new UserErrorException($"Could not add Content Mod '{projectDir}' because the version in the manifest.json file does not match the version {version} provided."); + throw GetError("no Version value was provided"); + 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}'"); + } + + UserErrorException GetError(string reasonPhrase) + { + return new UserErrorException($"The content pack at '{contentPackDir}' can't be loaded because {reasonPhrase}."); } } @@ -94,18 +108,14 @@ namespace StardewModdingAPI.ModBuildConfig.Framework if (ignoreFilePaths.Any(p => p == relativePath) || ignoreFilePatterns.Any(p => p.IsMatch(relativePath))) return true; - { - bool shouldIgnore = - // release zips - this.EqualsInvariant(file.Extension, ".zip") + // ignore special files + return + // release zips + this.EqualsInvariant(file.Extension, ".zip") - // OS metadata files - || this.EqualsInvariant(file.Name, ".DS_Store") - || this.EqualsInvariant(file.Name, "Thumbs.db"); - if (shouldIgnore) - return true; - } - return false; + // OS metadata files + || this.EqualsInvariant(file.Name, ".DS_Store") + || this.EqualsInvariant(file.Name, "Thumbs.db"); } // Get whether a string is equal to another case-insensitively. diff --git a/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs index c7e47c94..e785094e 100644 --- a/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/IModFileManager.cs @@ -3,6 +3,7 @@ using System.IO; namespace StardewModdingAPI.ModBuildConfig.Framework { + /// Manages the files that are part of a mod in the release package. public interface IModFileManager { /// Get the files in the mod package. diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index b94589f4..708dc3ad 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -99,7 +99,7 @@ IgnoreModFilePaths="$(IgnoreModFilePaths)" BundleExtraAssemblies="$(BundleExtraAssemblies)" - ContentMods="@(Content)" + ContentPacks="@(ContentPacks)" /> From a8c9372299ea9bee3ba6738da136063fa47619e7 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 14 Sep 2024 15:17:39 -0400 Subject: [PATCH 5/5] tweak mod file manager naming --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 4 ++-- .../{ContentModManager.cs => ContentPackFileManager.cs} | 4 ++-- .../Framework/{CSharpModManager.cs => MainModFileManager.cs} | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/SMAPI.ModBuildConfig/Framework/{ContentModManager.cs => ContentPackFileManager.cs} (96%) rename src/SMAPI.ModBuildConfig/Framework/{CSharpModManager.cs => MainModFileManager.cs} (97%) diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 77bb8658..b131008d 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -128,7 +128,7 @@ namespace StardewModdingAPI.ModBuildConfig var modPackages = new Dictionary { - [this.ModFolderName] = new CSharpModManager(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, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip) }; if (this.ContentPacks != null) @@ -158,7 +158,7 @@ namespace StardewModdingAPI.ModBuildConfig this.Log.LogMessage(MessageImportance.High, $"[mod build package] Bundling content pack: {folderName} v{version} at {contentPath}."); modPackages.Add( folderName, - new ContentPatcherModManager(this.ProjectDir, contentPath, version, ignoreFilePaths, ignoreFilePatterns, validateManifest) + new ContentPackFileManager(this.ProjectDir, contentPath, version, ignoreFilePaths, ignoreFilePatterns, validateManifest) ); } } diff --git a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs similarity index 96% rename from src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs rename to src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs index 44bfcfa4..51a6485f 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ContentModManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ContentPackFileManager.cs @@ -13,7 +13,7 @@ using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework { /// Manages the files that are part of a bundled content pack. - internal class ContentPatcherModManager : IModFileManager + internal class ContentPackFileManager : IModFileManager { /********* ** Fields @@ -36,7 +36,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework /// 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 ContentPatcherModManager(string projectDir, string contentPackDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateManifest) + public ContentPackFileManager(string projectDir, string contentPackDir, string version, string[] ignoreFilePaths, Regex[] ignoreFilePatterns, bool validateManifest) { // get folders DirectoryInfo projectDirInfo = new DirectoryInfo(Path.Combine(projectDir, contentPackDir)); diff --git a/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs similarity index 97% rename from src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs rename to src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs index 1249241d..0321c4e5 100644 --- a/src/SMAPI.ModBuildConfig/Framework/CSharpModManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/MainModFileManager.cs @@ -8,7 +8,7 @@ using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework { /// Manages the files that are part of the main C# mod. - internal class CSharpModManager : IModFileManager + internal class MainModFileManager : IModFileManager { /********* ** Fields @@ -71,7 +71,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework /// The name (without extension or path) for the current mod's DLL. /// Whether to validate that required mod files like the manifest are present. /// The mod package isn't valid. - public CSharpModManager(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, bool validateRequiredModFiles) { // validate paths if (!Directory.Exists(projectDir))