diff options
-rw-r--r-- | src/SMAPI/Framework/ContentPack.cs | 78 | ||||
-rw-r--r-- | src/SMAPI/Framework/IModMetadata.cs | 18 | ||||
-rw-r--r-- | src/SMAPI/Framework/InternalExtensions.cs | 2 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModHelpers/ModHelper.cs | 19 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModLoading/ModMetadata.cs | 29 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModLoading/ModResolver.cs | 151 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModRegistry.cs | 17 | ||||
-rw-r--r-- | src/SMAPI/Framework/Models/Manifest.cs | 6 | ||||
-rw-r--r-- | src/SMAPI/Framework/Models/ManifestContentPackFor.cs | 15 | ||||
-rw-r--r-- | src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs | 50 | ||||
-rw-r--r-- | src/SMAPI/IContentPack.cs | 42 | ||||
-rw-r--r-- | src/SMAPI/IManifest.cs | 5 | ||||
-rw-r--r-- | src/SMAPI/IManifestContentPackFor.cs | 12 | ||||
-rw-r--r-- | src/SMAPI/IModHelper.cs | 12 | ||||
-rw-r--r-- | src/SMAPI/Program.cs | 90 | ||||
-rw-r--r-- | src/SMAPI/StardewModdingAPI.csproj | 7 |
16 files changed, 485 insertions, 68 deletions
diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs new file mode 100644 index 00000000..0a8f223e --- /dev/null +++ b/src/SMAPI/Framework/ContentPack.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Serialisation; +using xTile; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Manages access to a content pack's metadata and files.</summary> + internal class ContentPack : IContentPack + { + /********* + ** Properties + *********/ + /// <summary>Provides an API for loading content assets.</summary> + private readonly IContentHelper Content; + + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// <summary>The full path to the content pack's folder.</summary> + public string DirectoryPath { get; } + + /// <summary>The content pack's manifest.</summary> + public IManifest Manifest { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="directoryPath">The full path to the content pack's folder.</param> + /// <param name="manifest">The content pack's manifest.</param> + /// <param name="content">Provides an API for loading content assets.</param> + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, JsonHelper jsonHelper) + { + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Content = content; + this.JsonHelper = jsonHelper; + } + + /// <summary>Read a JSON file from the content pack folder.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the contnet directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + public TModel ReadJsonFile<TModel>(string path) where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile<TModel>(path); + } + + /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="key">The local path to a content file relative to the content pack folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + public T LoadAsset<T>(string key) + { + return this.Content.Load<T>(key, ContentSource.ModFolder); + } + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The the local path to a content file relative to the content pack folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetActualAssetKey(string key) + { + return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); + } + + } +} diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index a91b0a5b..d1e8eb7d 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Framework /// <summary>The mod manifest.</summary> IManifest Manifest { get; } - /// <summary>>Metadata about the mod from SMAPI's internal data (if any).</summary> + /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> ParsedModDataRecord DataRecord { get; } /// <summary>The metadata resolution status.</summary> @@ -27,12 +27,21 @@ namespace StardewModdingAPI.Framework /// <summary>The reason the metadata is invalid, if any.</summary> string Error { get; } - /// <summary>The mod instance (if it was loaded).</summary> + /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> IMod Mod { get; } + /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + IContentPack ContentPack { get; } + + /// <summary>Writes messages to the console and log file as this mod.</summary> + IMonitor Monitor { get; } + /// <summary>The mod-provided API (if any).</summary> object Api { get; } + /// <summary>Whether the mod is a content pack.</summary> + bool IsContentPack { get; } + /********* ** Public methods @@ -47,6 +56,11 @@ namespace StardewModdingAPI.Framework /// <param name="mod">The mod instance to set.</param> IModMetadata SetMod(IMod mod); + /// <summary>Set the mod instance.</summary> + /// <param name="contentPack">The contentPack instance to set.</param> + /// <param name="monitor">Writes messages to the console and log file.</param> + IModMetadata SetMod(IContentPack contentPack, IMonitor monitor); + /// <summary>Set the mod-provided API instance.</summary> /// <param name="api">The mod-provided API.</param> IModMetadata SetApi(object api); diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index 0340a92d..71489627 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -94,7 +94,7 @@ namespace StardewModdingAPI.Framework /// <param name="level">The log severity level.</param> public static void LogAsMod(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace) { - metadata.Mod.Monitor.Log(message, level); + metadata.Monitor.Log(message, level); } /**** diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 665b9cf4..c73dc307 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,5 +1,7 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using StardewModdingAPI.Framework.Serialisation; namespace StardewModdingAPI.Framework.ModHelpers @@ -13,6 +15,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <summary>The content packs loaded for this mod.</summary> + private readonly IContentPack[] ContentPacks; + /********* ** Accessors @@ -48,9 +53,10 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="modRegistry">an API for fetching metadata about loaded mods.</param> /// <param name="reflectionHelper">An API for accessing private game code.</param> /// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param> + /// <param name="contentPacks">The content packs loaded for this mod.</param> /// <exception cref="ArgumentNullException">An argument is null or empty.</exception> /// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception> - public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper) + public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper, IEnumerable<IContentPack> contentPacks) : base(modID) { // validate directory @@ -67,6 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); + this.ContentPacks = contentPacks.ToArray(); } /**** @@ -116,6 +123,14 @@ namespace StardewModdingAPI.Framework.ModHelpers this.JsonHelper.WriteJsonFile(path, model); } + /**** + ** Content packs + ****/ + /// <summary>Get all content packs loaded for this mod.</summary> + public IEnumerable<IContentPack> GetContentPacks() + { + return this.ContentPacks; + } /**** ** Disposal diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 29bb6617..1a0f9994 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,3 +1,4 @@ +using System; using StardewModdingAPI.Framework.ModData; namespace StardewModdingAPI.Framework.ModLoading @@ -26,12 +27,21 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The reason the metadata is invalid, if any.</summary> public string Error { get; private set; } - /// <summary>The mod instance (if it was loaded).</summary> + /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> public IMod Mod { get; private set; } + /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + public IContentPack ContentPack { get; private set; } + + /// <summary>Writes messages to the console and log file as this mod.</summary> + public IMonitor Monitor { get; private set; } + /// <summary>The mod-provided API (if any).</summary> public object Api { get; private set; } + /// <summary>Whether the mod is a content pack.</summary> + public bool IsContentPack => this.Manifest?.ContentPackFor != null; + /********* ** Public methods @@ -64,7 +74,24 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="mod">The mod instance to set.</param> public IModMetadata SetMod(IMod mod) { + if (this.ContentPack != null) + throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); + this.Mod = mod; + this.Monitor = mod.Monitor; + return this; + } + + /// <summary>Set the mod instance.</summary> + /// <param name="contentPack">The contentPack instance to set.</param> + /// <param name="monitor">Writes messages to the console and log file.</param> + public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor) + { + if (this.Mod != null) + throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); + + this.ContentPack = contentPack; + this.Monitor = monitor; return this; } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index b46ee117..be73254d 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -30,18 +30,13 @@ namespace StardewModdingAPI.Framework.ModLoading string error = null; try { - // read manifest manifest = jsonHelper.ReadJsonFile<Manifest>(path); - - // validate if (manifest == null) { error = File.Exists(path) ? "its manifest is invalid." : "it doesn't have a manifest."; } - else if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - error = "its manifest doesn't set an entry DLL."; } catch (SParseException ex) { @@ -85,7 +80,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (mod.Status == ModMetadataStatus.Failed) continue; - // validate compatibility + // validate compatibility from internal data switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: @@ -128,24 +123,52 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // validate DLL value - if (string.IsNullOrWhiteSpace(mod.Manifest.EntryDll)) - { - mod.SetStatus(ModMetadataStatus.Failed, "its manifest has no EntryDLL field."); - continue; - } - if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + // validate DLL / content pack fields { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); - continue; - } + bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll); + bool isContentPack = mod.Manifest.ContentPackFor != null; - // validate DLL path - string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); - if (!File.Exists(assemblyPath)) - { - mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); - continue; + // validate field presence + if (!hasDll && !isContentPack) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); + continue; + } + if (hasDll && isContentPack) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); + continue; + } + + // validate DLL + if (hasDll) + { + // invalid filename format + if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); + continue; + } + + // invalid path + string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + continue; + } + } + + // validate content pack + else + { + // invalid content pack ID + if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); + continue; + } + } } // validate required fields @@ -243,30 +266,17 @@ namespace StardewModdingAPI.Framework.ModLoading throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); } - // no dependencies, mark sorted - if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any()) + // collect dependencies + ModDependency[] dependencies = this.GetDependenciesFrom(mod.Manifest, mods).ToArray(); + + // mark sorted if no dependencies + if (!dependencies.Any()) { sortedMods.Push(mod); return states[mod] = ModDependencyStatus.Sorted; } - // get dependencies - var dependencies = - ( - from entry in mod.Manifest.Dependencies - let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) - orderby entry.UniqueID - select new - { - ID = entry.UniqueID, - MinVersion = entry.MinimumVersion, - Mod = dependencyMod, - IsRequired = entry.IsRequired - } - ) - .ToArray(); - - // missing required dependencies, mark failed + // mark failed if missing dependencies { string[] failedModNames = ( from entry in dependencies @@ -371,5 +381,64 @@ namespace StardewModdingAPI.Framework.ModLoading yield return directory; } } + + /// <summary>Get the dependencies declared in a manifest.</summary> + /// <param name="manifest">The mod manifest.</param> + /// <param name="loadedMods">The loaded mods.</param> + private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) + { + IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, id, StringComparison.InvariantCultureIgnoreCase)); + + // yield dependencies + if (manifest.Dependencies != null) + { + foreach (var entry in manifest.Dependencies) + yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired); + } + + // yield content pack parent + if (manifest.ContentPackFor != null) + yield return new ModDependency(manifest.ContentPackFor.UniqueID, manifest.ContentPackFor.MinimumVersion, FindMod(manifest.ContentPackFor.UniqueID), isRequired: true); + } + + + /********* + ** Private models + *********/ + /// <summary>Represents a dependency from one mod to another.</summary> + private struct ModDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the required mod.</summary> + public string ID { get; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinVersion { get; } + + /// <summary>Whether the mod shouldn't be loaded if the dependency isn't available.</summary> + public bool IsRequired { get; } + + /// <summary>The loaded mod that fulfills the dependency (if available).</summary> + public IModMetadata Mod { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The unique ID of the required mod.</param> + /// <param name="minVersion">The minimum required version (if any).</param> + /// <param name="mod">The loaded mod that fulfills the dependency (if available).</param> + /// <param name="isRequired">Whether the mod shouldn't be loaded if the dependency isn't available.</param> + public ModDependency(string id, ISemanticVersion minVersion, IModMetadata mod, bool isRequired) + { + this.ID = id; + this.MinVersion = minVersion; + this.Mod = mod; + this.IsRequired = isRequired; + } + } } } diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index 453d2868..e7d4f89a 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -25,18 +25,27 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ - /// <summary>Register a mod as a possible source of deprecation warnings.</summary> + /// <summary>Register a mod.</summary> /// <param name="metadata">The mod metadata.</param> public void Add(IModMetadata metadata) { this.Mods.Add(metadata); - this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; + if (!metadata.IsContentPack) + this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; } /// <summary>Get metadata for all loaded mods.</summary> - public IEnumerable<IModMetadata> GetAll() + /// <param name="assemblyMods">Whether to include SMAPI mods.</param> + /// <param name="contentPacks">Whether to include content pack mods.</param> + public IEnumerable<IModMetadata> GetAll(bool assemblyMods = true, bool contentPacks = true) { - return this.Mods.Select(p => p); + IEnumerable<IModMetadata> query = this.Mods; + if (!assemblyMods) + query = query.Where(p => p.IsContentPack); + if (!contentPacks) + query = query.Where(p => !p.IsContentPack); + + return query; } /// <summary>Get metadata for a loaded mod.</summary> diff --git a/src/SMAPI/Framework/Models/Manifest.cs b/src/SMAPI/Framework/Models/Manifest.cs index f9762406..74303cba 100644 --- a/src/SMAPI/Framework/Models/Manifest.cs +++ b/src/SMAPI/Framework/Models/Manifest.cs @@ -27,9 +27,13 @@ namespace StardewModdingAPI.Framework.Models [JsonConverter(typeof(SemanticVersionConverter))] public ISemanticVersion MinimumApiVersion { get; set; } - /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method.</summary> + /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary> public string EntryDll { get; set; } + /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="IManifest.EntryDll"/>.</summary> + [JsonConverter(typeof(ManifestContentPackForConverter))] + public IManifestContentPackFor ContentPackFor { get; set; } + /// <summary>The other mods that must be loaded before this mod.</summary> [JsonConverter(typeof(ManifestDependencyArrayConverter))] public IManifestDependency[] Dependencies { get; set; } diff --git a/src/SMAPI/Framework/Models/ManifestContentPackFor.cs b/src/SMAPI/Framework/Models/ManifestContentPackFor.cs new file mode 100644 index 00000000..7836bbcc --- /dev/null +++ b/src/SMAPI/Framework/Models/ManifestContentPackFor.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary> + internal class ManifestContentPackFor : IManifestContentPackFor + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod which can read this content pack.</summary> + public string UniqueID { get; set; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinimumVersion { get; set; } + } +} diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs new file mode 100644 index 00000000..af7558f6 --- /dev/null +++ b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs @@ -0,0 +1,50 @@ +using System; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters +{ + /// <summary>Handles deserialisation of <see cref="IManifestContentPackFor"/> arrays.</summary> + internal class ManifestContentPackForConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Whether this converter can write JSON.</summary> + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return objectType == typeof(IManifestContentPackFor[]); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Read the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return serializer.Deserialize<ManifestContentPackFor>(reader); + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs new file mode 100644 index 00000000..15a2b7dd --- /dev/null +++ b/src/SMAPI/IContentPack.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using xTile; + +namespace StardewModdingAPI +{ + /// <summary>An API that provides access to a content pack.</summary> + public interface IContentPack + { + /********* + ** Accessors + *********/ + /// <summary>The full path to the content pack's folder.</summary> + string DirectoryPath { get; } + + /// <summary>The content pack's manifest.</summary> + IManifest Manifest { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Read a JSON file from the content pack folder.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the content pack directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + TModel ReadJsonFile<TModel>(string path) where TModel : class; + + /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="key">The local path to a content file relative to the content pack folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + T LoadAsset<T>(string key); + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The the local path to a content file relative to the content pack folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + string GetActualAssetKey(string key); + } +} diff --git a/src/SMAPI/IManifest.cs b/src/SMAPI/IManifest.cs index 9db1d538..183ac105 100644 --- a/src/SMAPI/IManifest.cs +++ b/src/SMAPI/IManifest.cs @@ -26,9 +26,12 @@ namespace StardewModdingAPI /// <summary>The unique mod ID.</summary> string UniqueID { get; } - /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method.</summary> + /// <summary>The name of the DLL in the directory that has the <see cref="IMod.Entry"/> method. Mutually exclusive with <see cref="EntryDll"/>.</summary> string EntryDll { get; } + /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="EntryDll"/>.</summary> + IManifestContentPackFor ContentPackFor { get; } + /// <summary>The other mods that must be loaded before this mod.</summary> IManifestDependency[] Dependencies { get; } diff --git a/src/SMAPI/IManifestContentPackFor.cs b/src/SMAPI/IManifestContentPackFor.cs new file mode 100644 index 00000000..f05a3873 --- /dev/null +++ b/src/SMAPI/IManifestContentPackFor.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary> + public interface IManifestContentPackFor + { + /// <summary>The unique ID of the mod which can read this content pack.</summary> + string UniqueID { get; } + + /// <summary>The minimum required version (if any).</summary> + ISemanticVersion MinimumVersion { get; } + } +} diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index 116e8508..96265c85 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -1,4 +1,6 @@ -namespace StardewModdingAPI +using System.Collections.Generic; + +namespace StardewModdingAPI { /// <summary>Provides simplified APIs for writing mods.</summary> public interface IModHelper @@ -54,5 +56,11 @@ /// <param name="path">The file path relative to the mod directory.</param> /// <param name="model">The model to save.</param> void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; + + /**** + ** Content packs + ****/ + /// <summary>Get all content packs loaded for this mod.</summary> + IEnumerable<IContentPack> GetContentPacks(); } -}
\ No newline at end of file +} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index fd2bb340..e0064714 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -394,7 +394,7 @@ namespace StardewModdingAPI LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll()) + foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); } @@ -652,15 +652,52 @@ namespace StardewModdingAPI { this.Monitor.Log("Loading mods...", LogLevel.Trace); - // load mod assemblies IDictionary<IModMetadata, string> skippedMods = new Dictionary<IModMetadata, string>(); + void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + + // load content packs + foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack)) + { + // get basic info + IManifest manifest = metadata.Manifest; + this.Monitor.Log($"Loading {metadata.DisplayName} from {metadata.DirectoryPath.Replace(Constants.ModPath, "").TrimStart(Path.DirectorySeparatorChar)} (content pack)...", LogLevel.Trace); + + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + TrackSkip(metadata, metadata.Error); + continue; + } + + // load mod as content pack + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + IContentHelper contentHelper = new ContentHelper(contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper); + metadata.SetMod(contentPack, monitor); + this.ModRegistry.Add(metadata); + } + IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); + + // load mods { - void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + // get content packs by mod ID + IDictionary<string, IContentPack[]> contentPacksByModID = + loadedContentPacks + .GroupBy(p => p.Manifest.ContentPackFor.UniqueID) + .ToDictionary( + group => group.Key, + group => group.Select(metadata => metadata.ContentPack).ToArray(), + StringComparer.InvariantCultureIgnoreCase + ); + // get assembly loaders AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor, this.Settings.DeveloperMode); AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - foreach (IModMetadata metadata in mods) + + // load from metadata + foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack)) { // get basic info IManifest manifest = metadata.Manifest; @@ -676,7 +713,7 @@ namespace StardewModdingAPI continue; } - // preprocess & load mod assembly + // load mod string assemblyPath = metadata.Manifest?.EntryDll != null ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) : null; @@ -704,6 +741,10 @@ namespace StardewModdingAPI // initialise mod try { + // get content packs + if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks)) + contentPacks = new IContentPack[0]; + // init mod helpers IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); IModHelper modHelper; @@ -713,7 +754,7 @@ namespace StardewModdingAPI IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); - modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper, contentPacks); } // get mod instance @@ -735,7 +776,7 @@ namespace StardewModdingAPI } } } - IModMetadata[] loadedMods = this.ModRegistry.GetAll().ToArray(); + IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); // log skipped mods this.Monitor.Newline(); @@ -757,6 +798,7 @@ namespace StardewModdingAPI // log loaded mods this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) { IManifest manifest = metadata.Manifest; @@ -769,10 +811,30 @@ namespace StardewModdingAPI } this.Monitor.Newline(); + // log loaded content packs + if (loadedContentPacks.Any()) + { + string GetModDisplayName(string id) => loadedMods.First(p => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.DisplayName; + + this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); + foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (metadata.IsContentPack ? $" | content pack for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + this.Monitor.Newline(); + } + // initialise translations - this.ReloadTranslations(); + this.ReloadTranslations(loadedMods); - // initialise loaded mods + // initialise loaded non-content-pack mods foreach (IModMetadata metadata in loadedMods) { // add interceptors @@ -891,11 +953,15 @@ namespace StardewModdingAPI } /// <summary>Reload translations for all mods.</summary> - private void ReloadTranslations() + /// <param name="mods">The mods for which to reload translations.</param> + private void ReloadTranslations(IEnumerable<IModMetadata> mods) { JsonHelper jsonHelper = new JsonHelper(); - foreach (IModMetadata metadata in this.ModRegistry.GetAll()) + foreach (IModMetadata metadata in mods) { + if (metadata.IsContentPack) + throw new InvalidOperationException("Can't reload translations for a content pack."); + // read translation files IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); @@ -954,7 +1020,7 @@ namespace StardewModdingAPI break; case "reload_i18n": - this.ReloadTranslations(); + this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); break; diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index eb403309..7cf62a91 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -85,6 +85,7 @@ <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> + <Compile Include="Framework\ContentPack.cs" /> <Compile Include="Framework\Content\ContentCache.cs" /> <Compile Include="Framework\Input\InputState.cs" /> <Compile Include="Framework\Input\InputStatus.cs" /> @@ -93,6 +94,7 @@ <Compile Include="Framework\ModData\ModDataField.cs" /> <Compile Include="Framework\ModData\ModDataFieldKey.cs" /> <Compile Include="Framework\ModData\ParsedModDataRecord.cs" /> + <Compile Include="Framework\Models\ManifestContentPackFor.cs" /> <Compile Include="Framework\ModLoading\Finders\EventFinder.cs" /> <Compile Include="Framework\ModLoading\Finders\FieldFinder.cs" /> <Compile Include="Framework\ModLoading\Finders\MethodFinder.cs" /> @@ -114,6 +116,7 @@ <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyFactory.cs" /> + <Compile Include="Framework\Serialisation\SmapiConverters\ManifestContentPackForConverter.cs" /> <Compile Include="Framework\Serialisation\SmapiConverters\ManifestDependencyArrayConverter.cs" /> <Compile Include="Framework\Serialisation\SmapiConverters\SemanticVersionConverter.cs" /> <Compile Include="Framework\Serialisation\SimpleReadOnlyConverter.cs" /> @@ -121,6 +124,8 @@ <Compile Include="Framework\Serialisation\CrossplatformConverters\ColorConverter.cs" /> <Compile Include="Framework\Serialisation\CrossplatformConverters\PointConverter.cs" /> <Compile Include="Framework\Utilities\ContextHash.cs" /> + <Compile Include="IContentPack.cs" /> + <Compile Include="IManifestContentPackFor.cs" /> <Compile Include="IReflectedField.cs" /> <Compile Include="IReflectedMethod.cs" /> <Compile Include="IReflectedProperty.cs" /> @@ -279,4 +284,4 @@ </ItemGroup> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="..\..\build\common.targets" /> -</Project>
\ No newline at end of file +</Project> |