diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets
index bd84ee11b..3fc4720d2 100644
--- a/build/deploy-local-smapi.targets
+++ b/build/deploy-local-smapi.targets
@@ -21,7 +21,10 @@ This assumes `find-game-folder.targets` has already been imported and validated.
+
+
+
diff --git a/build/unix/prepare-install-package.sh b/build/unix/prepare-install-package.sh
index bd7df36a8..c7d7a7756 100755
--- a/build/unix/prepare-install-package.sh
+++ b/build/unix/prepare-install-package.sh
@@ -134,7 +134,7 @@ for folder in ${folders[@]}; do
cp -r "$smapiBin/i18n" "$bundlePath/smapi-internal"
# bundle smapi-internal
- for name in "0Harmony.dll" "0Harmony.xml" "Mono.Cecil.dll" "Mono.Cecil.Mdb.dll" "Mono.Cecil.Pdb.dll" "MonoMod.Common.dll" "Newtonsoft.Json.dll" "Pathoschild.Http.Client.dll" "Pintail.dll" "TMXTile.dll" "SMAPI.Toolkit.dll" "SMAPI.Toolkit.xml" "SMAPI.Toolkit.CoreInterfaces.dll" "SMAPI.Toolkit.CoreInterfaces.xml" "System.Net.Http.Formatting.dll"; do
+ for name in "0Harmony.dll" "0Harmony.xml" "Mono.Cecil.dll" "Mono.Cecil.Mdb.dll" "Mono.Cecil.Pdb.dll" "MonoMod.Common.dll" "Namotion.Reflection.dll" "Newtonsoft.Json.dll" "NJsonSchema.dll" "NJsonSchema.Annotations.dll" "Pathoschild.Http.Client.dll" "Pintail.dll" "TMXTile.dll" "SMAPI.Toolkit.dll" "SMAPI.Toolkit.xml" "SMAPI.Toolkit.CoreInterfaces.dll" "SMAPI.Toolkit.CoreInterfaces.xml" "System.Net.Http.Formatting.dll"; do
cp "$smapiBin/$name" "$bundlePath/smapi-internal"
done
diff --git a/build/windows/prepare-install-package.ps1 b/build/windows/prepare-install-package.ps1
index 08ed88fca..d02070ef5 100644
--- a/build/windows/prepare-install-package.ps1
+++ b/build/windows/prepare-install-package.ps1
@@ -155,7 +155,7 @@ foreach ($folder in $folders) {
cp -Recurse "$smapiBin/i18n" "$bundlePath/smapi-internal"
# bundle smapi-internal
- foreach ($name in @("0Harmony.dll", "0Harmony.xml", "Mono.Cecil.dll", "Mono.Cecil.Mdb.dll", "Mono.Cecil.Pdb.dll", "MonoMod.Common.dll", "Newtonsoft.Json.dll", "Pathoschild.Http.Client.dll", "Pintail.dll", "TMXTile.dll", "SMAPI.Toolkit.dll", "SMAPI.Toolkit.xml", "SMAPI.Toolkit.CoreInterfaces.dll", "SMAPI.Toolkit.CoreInterfaces.xml", "System.Net.Http.Formatting.dll")) {
+ foreach ($name in @("0Harmony.dll", "0Harmony.xml", "Mono.Cecil.dll", "Mono.Cecil.Mdb.dll", "Mono.Cecil.Pdb.dll", "MonoMod.Common.dll", "Namotion.Reflection.dll", "Newtonsoft.Json.dll", "NJsonSchema.dll", "NJsonSchema.Annotations.dll", "Pathoschild.Http.Client.dll", "Pintail.dll", "TMXTile.dll", "SMAPI.Toolkit.dll", "SMAPI.Toolkit.xml", "SMAPI.Toolkit.CoreInterfaces.dll", "SMAPI.Toolkit.CoreInterfaces.xml", "System.Net.Http.Formatting.dll")) {
cp "$smapiBin/$name" "$bundlePath/smapi-internal"
}
diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
index 2ad7798a7..028df7fc4 100644
--- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
+++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs
index 208cd6567..539ef344e 100644
--- a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs
+++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs
@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
+using NJsonSchema;
using StardewModdingAPI.Toolkit.Serialization.Converters;
namespace StardewModdingAPI.Toolkit.Serialization
@@ -45,7 +45,7 @@ public static JsonSerializerSettings CreateDefaultSettings()
/// The file contains invalid JSON.
public bool ReadJsonFileIfExists(string fullPath,
#if NET6_0_OR_GREATER
- [NotNullWhen(true)]
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)]
#endif
out TModel? result
)
@@ -100,9 +100,7 @@ public void WriteJsonFile(string fullPath, TModel model)
throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath));
// create directory if needed
- string dir = Path.GetDirectoryName(fullPath)!;
- if (dir == null)
- throw new ArgumentException("The file path is invalid.", nameof(fullPath));
+ string dir = Path.GetDirectoryName(fullPath) ?? throw new ArgumentException("The file path is invalid.", nameof(fullPath));
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
@@ -111,6 +109,28 @@ public void WriteJsonFile(string fullPath, TModel model)
File.WriteAllText(fullPath, json);
}
+ /// Save a data model schema to a JSON file.
+ /// The model type.
+ /// The absolute file path.
+ /// The given path is empty or invalid.
+ public void WriteJsonSchemaFile(string fullPath)
+ where TModel : class
+ {
+ // validate
+ if (string.IsNullOrWhiteSpace(fullPath))
+ throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath));
+
+ // create directory if needed
+ string dir = Path.GetDirectoryName(fullPath) ?? throw new ArgumentException("The file path is invalid.", nameof(fullPath));
+ if (!Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ // write file
+ JsonSchema schema = JsonSchema.FromType();
+ string json = schema.ToJson();
+ File.WriteAllText(fullPath, json);
+ }
+
/// Deserialize JSON text if possible.
/// The model type.
/// The raw JSON text.
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index 2eaa940a7..69b20b1ac 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -28,7 +28,7 @@ internal class DataHelper : BaseHelper, IDataHelper
/// Construct an instance.
/// The mod using this instance.
/// The absolute path to the mod folder.
- /// The absolute path to the mod folder.
+ /// Encapsulates SMAPI's JSON file parsing.
public DataHelper(IModMetadata mod, string modFolderPath, JsonHelper jsonHelper)
: base(mod)
{
@@ -67,6 +67,18 @@ public void WriteJsonFile(string path, TModel? data)
File.Delete(path);
}
+ ///
+ public void WriteJsonSchemaFile(string path)
+ where TModel : class
+ {
+ if (!PathUtilities.IsSafeRelativePath(path))
+ throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonSchemaFile)} with a relative path (without directory climbing).");
+
+ path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path));
+
+ this.JsonHelper.WriteJsonSchemaFile(path);
+ }
+
/****
** Save file
****/
diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
index d1cf357e9..e48052aab 100644
--- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
@@ -8,6 +8,13 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// Provides simplified APIs for writing mods.
internal class ModHelper : BaseHelper, IModHelper, IDisposable
{
+ /*********
+ ** Fields
+ *********/
+ /// Whether to generate a config.schema.json file based on the mod's config model when it's loaded or saved.
+ private readonly bool GenerateConfigSchema;
+
+
/*********
** Accessors
*********/
@@ -65,9 +72,10 @@ internal class ModHelper : BaseHelper, IModHelper, IDisposable
/// An API for accessing private game code.
/// Provides multiplayer utilities.
/// An API for reading translations stored in the mod's i18n folder.
+ /// Whether to generate a config.schema.json file based on the mod's config model when it's loaded or saved.
/// An argument is null or empty.
/// The path does not exist on disk.
- public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper)
+ public ModHelper(IModMetadata mod, string modDirectory, Func currentInputState, IModEvents events, IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, bool generateConfigSchema)
: base(mod)
{
// validate directory
@@ -89,6 +97,7 @@ public ModHelper(IModMetadata mod, string modDirectory, Func curren
this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer));
this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper));
this.Events = events;
+ this.GenerateConfigSchema = generateConfigSchema;
}
/****
@@ -108,6 +117,9 @@ public void WriteConfig(TConfig config)
where TConfig : class, new()
{
this.Data.WriteJsonFile("config.json", config);
+
+ if (this.GenerateConfigSchema)
+ this.Data.WriteJsonSchemaFile("config.schema.json");
}
/****
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 40bdb1304..5f4e8e66d 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -26,7 +26,8 @@ internal class SConfig
[nameof(RewriteMods)] = true,
[nameof(FixHarmony)] = true,
[nameof(UseCaseInsensitivePaths)] = Constants.Platform is Platform.Android or Platform.Linux,
- [nameof(SuppressHarmonyDebugMode)] = true
+ [nameof(SuppressHarmonyDebugMode)] = true,
+ [nameof(GenerateConfigSchemas)] = false
};
/// The default values for , to log changes if different.
@@ -99,6 +100,9 @@ internal class SConfig
/// The mod IDs SMAPI should load after any other mods.
public HashSet ModsToLoadLate { get; set; }
+ /// Whether to generate config.schema.json files for external tools like mod managers.
+ public bool GenerateConfigSchemas { get; set; }
+
/********
** Public methods
@@ -122,7 +126,8 @@ internal class SConfig
///
///
///
- public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate)
+ ///
+ public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate, bool? generateConfigSchemas)
{
this.DeveloperMode = developerMode;
this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)];
@@ -142,6 +147,7 @@ public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsole
this.SuppressUpdateChecks = new HashSet(suppressUpdateChecks ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
this.ModsToLoadEarly = new HashSet(modsToLoadEarly ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
this.ModsToLoadLate = new HashSet(modsToLoadLate ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
+ this.GenerateConfigSchemas = generateConfigSchemas ?? (bool)SConfig.DefaultValues[nameof(this.GenerateConfigSchemas)];
}
/// Override the value of .
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 0a6221c84..a894fa7a2 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -2050,7 +2050,7 @@ IContentPack[] GetContentPacks()
IModRegistry modRegistryHelper = new ModRegistryHelper(mod, this.ModRegistry, proxyFactory, monitor);
IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(mod, this.Multiplayer);
- modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
+ modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, this.Settings.GenerateConfigSchemas);
}
// init mod
diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs
index 7ddf851ef..ff778bc24 100644
--- a/src/SMAPI/IDataHelper.cs
+++ b/src/SMAPI/IDataHelper.cs
@@ -27,6 +27,13 @@ public interface IDataHelper
void WriteJsonFile(string path, TModel? data)
where TModel : class;
+ /// Save a data model schema to a JSON file in the mod's folder.
+ /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.
+ /// The file path relative to the mod folder.
+ /// The is not relative or contains directory climbing (../).
+ void WriteJsonSchemaFile(string path)
+ where TModel : class;
+
/****
** Save file
****/
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 55f9869b6..42c3339cf 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -172,5 +172,12 @@ in future SMAPI versions.
* the mod author.
*/
"ModsToLoadEarly": [],
- "ModsToLoadLate": []
+ "ModsToLoadLate": [],
+
+ /**
+ * Whether to generate a `config.schema.json` file next to each mod's `config.json` file.
+ *
+ * This can be used by separate tools like mod managers to enable config editing features.
+ */
+ "GenerateConfigSchemas": false
}