From 024489c33827ce8e1463eac199daa996a8a99216 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 16 Feb 2018 18:50:46 -0500 Subject: overhaul internal mod data format (#439) The new format is much more concise, reduces the memory footprint by only parsing metadata for loaded mods, and adds support for versioning and defaulting most fields. --- src/SMAPI/Framework/Models/ModCompatibility.cs | 55 ------ src/SMAPI/Framework/Models/ModDataField.cs | 82 ++++++++ src/SMAPI/Framework/Models/ModDataFieldKey.cs | 18 ++ src/SMAPI/Framework/Models/ModDataID.cs | 85 --------- src/SMAPI/Framework/Models/ModDataRecord.cs | 220 ++++++++++++++++++++-- src/SMAPI/Framework/Models/ParsedModDataRecord.cs | 48 +++++ 6 files changed, 348 insertions(+), 160 deletions(-) delete mode 100644 src/SMAPI/Framework/Models/ModCompatibility.cs create mode 100644 src/SMAPI/Framework/Models/ModDataField.cs create mode 100644 src/SMAPI/Framework/Models/ModDataFieldKey.cs delete mode 100644 src/SMAPI/Framework/Models/ModDataID.cs create mode 100644 src/SMAPI/Framework/Models/ParsedModDataRecord.cs (limited to 'src/SMAPI/Framework/Models') diff --git a/src/SMAPI/Framework/Models/ModCompatibility.cs b/src/SMAPI/Framework/Models/ModCompatibility.cs deleted file mode 100644 index 54737e6c..00000000 --- a/src/SMAPI/Framework/Models/ModCompatibility.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; - -namespace StardewModdingAPI.Framework.Models -{ - /// Specifies the compatibility of a given mod version range. - internal class ModCompatibility - { - /********* - ** Accessors - *********/ - /// The lowest version in the range, or null for all past versions. - public ISemanticVersion LowerVersion { get; } - - /// The highest version in the range, or null for all future versions. - public ISemanticVersion UpperVersion { get; } - - /// The mod compatibility. - public ModStatus Status { get; } - - /// The reason phrase to show in log output, or null to use the default value. - /// For example, "this version is incompatible with the latest version of the game". - public string ReasonPhrase { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// A version range, which consists of two version strings separated by a '~' character. Either side can be left blank for an unbounded range. - /// The mod compatibility. - /// The reason phrase to show in log output, or null to use the default value. - public ModCompatibility(string versionRange, ModStatus status, string reasonPhrase) - { - // extract version strings - string[] versions = versionRange.Split('~'); - if (versions.Length != 2) - throw new FormatException($"Could not parse '{versionRange}' as a version range. It must have two version strings separated by a '~' character (either side can be left blank for an unbounded range)."); - - // initialise - this.LowerVersion = !string.IsNullOrWhiteSpace(versions[0]) ? new SemanticVersion(versions[0]) : null; - this.UpperVersion = !string.IsNullOrWhiteSpace(versions[1]) ? new SemanticVersion(versions[1]) : null; - this.Status = status; - this.ReasonPhrase = reasonPhrase; - } - - /// Get whether a given version is contained within this compatibility range. - /// The version to check. - public bool MatchesVersion(ISemanticVersion version) - { - return - (this.LowerVersion == null || !version.IsOlderThan(this.LowerVersion)) - && (this.UpperVersion == null || !version.IsNewerThan(this.UpperVersion)); - } - } -} diff --git a/src/SMAPI/Framework/Models/ModDataField.cs b/src/SMAPI/Framework/Models/ModDataField.cs new file mode 100644 index 00000000..0812b39b --- /dev/null +++ b/src/SMAPI/Framework/Models/ModDataField.cs @@ -0,0 +1,82 @@ +using System.Linq; + +namespace StardewModdingAPI.Framework.Models +{ + /// A versioned mod metadata field. + internal class ModDataField + { + /********* + ** Accessors + *********/ + /// The field key. + public ModDataFieldKey Key { get; } + + /// The field value. + public string Value { get; } + + /// Whether this field should only be applied if it's not already set. + public bool IsDefault { get; } + + /// The lowest version in the range, or null for all past versions. + public ISemanticVersion LowerVersion { get; } + + /// The highest version in the range, or null for all future versions. + public ISemanticVersion UpperVersion { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The field key. + /// The field value. + /// Whether this field should only be applied if it's not already set. + /// The lowest version in the range, or null for all past versions. + /// The highest version in the range, or null for all future versions. + public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion lowerVersion, ISemanticVersion upperVersion) + { + this.Key = key; + this.Value = value; + this.IsDefault = isDefault; + this.LowerVersion = lowerVersion; + this.UpperVersion = upperVersion; + } + + /// Get whether this data field applies for the given manifest. + /// The mod manifest. + public bool IsMatch(IManifest manifest) + { + return + manifest?.Version != null // ignore invalid manifest + && (!this.IsDefault || !this.HasFieldValue(manifest, this.Key)) + && (this.LowerVersion == null || !manifest.Version.IsOlderThan(this.LowerVersion)) + && (this.UpperVersion == null || !manifest.Version.IsNewerThan(this.UpperVersion)); + } + + + /********* + ** Private methods + *********/ + /// Get whether a manifest field has a meaningful value for the purposes of enforcing . + /// The mod manifest. + /// The field key matching . + private bool HasFieldValue(IManifest manifest, ModDataFieldKey key) + { + switch (key) + { + // update key + case ModDataFieldKey.UpdateKey: + return manifest.UpdateKeys != null && manifest.UpdateKeys.Any(); + + // non-manifest fields + case ModDataFieldKey.AlternativeUrl: + case ModDataFieldKey.StatusReasonPhrase: + case ModDataFieldKey.Status: + return false; + + default: + return false; + } + } + } +} diff --git a/src/SMAPI/Framework/Models/ModDataFieldKey.cs b/src/SMAPI/Framework/Models/ModDataFieldKey.cs new file mode 100644 index 00000000..5767afc9 --- /dev/null +++ b/src/SMAPI/Framework/Models/ModDataFieldKey.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// The valid field keys. + public enum ModDataFieldKey + { + /// A manifest update key. + UpdateKey, + + /// An alternative URL the player can check for an updated version. + AlternativeUrl, + + /// The mod's predefined compatibility status. + Status, + + /// A reason phrase for the , or null to use the default reason. + StatusReasonPhrase + } +} diff --git a/src/SMAPI/Framework/Models/ModDataID.cs b/src/SMAPI/Framework/Models/ModDataID.cs deleted file mode 100644 index d19434fa..00000000 --- a/src/SMAPI/Framework/Models/ModDataID.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Linq; -using Newtonsoft.Json; - -namespace StardewModdingAPI.Framework.Models -{ - /// Uniquely identifies a mod in SMAPI's internal data. - /// - /// This represents a custom format which uniquely identifies a mod across all versions, even - /// if its field values change or it doesn't specify a unique ID. This is mapped to a string - /// with the following format: - /// - /// 1. If the mod's identifier changed over time, multiple variants can be separated by the | - /// character. - /// 2. Each variant can take one of two forms: - /// - A simple string matching the mod's UniqueID value. - /// - A JSON structure containing any of three manifest fields (ID, Name, and Author) to match. - /// - internal class ModDataID - { - /********* - ** Properties - *********/ - /// The unique sets of field values which identify this mod. - private readonly FieldSnapshot[] Snapshots; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - public ModDataID() { } - - /// Construct an instance. - /// The mod identifier string (see remarks on ). - public ModDataID(string data) - { - this.Snapshots = - ( - from string part in data.Split('|') - let str = part.Trim() - select str.StartsWith("{") - ? JsonConvert.DeserializeObject(str) - : new FieldSnapshot { ID = str } - ) - .ToArray(); - } - - /// Get whether this ID matches a given mod manifest. - /// The mod's unique ID, or a substitute ID if it isn't set in the manifest. - /// The manifest to check. - public bool Matches(string id, IManifest manifest) - { - return this.Snapshots.Any(snapshot => - snapshot.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase) - && ( - snapshot.Author == null - || snapshot.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase) - || (manifest.ExtraFields.ContainsKey("Authour") && snapshot.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase)) - ) - && (snapshot.Name == null || snapshot.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase)) - ); - } - - - /********* - ** Private models - *********/ - /// A unique set of fields which identifies the mod. - private class FieldSnapshot - { - /********* - ** Accessors - *********/ - /// The unique mod ID. - public string ID { get; set; } - - /// The mod name, or null to ignore the mod name. - public string Name { get; set; } - - /// The author name, or null to ignore the author. - public string Author { get; set; } - } - } -} diff --git a/src/SMAPI/Framework/Models/ModDataRecord.cs b/src/SMAPI/Framework/Models/ModDataRecord.cs index 580acb70..2c26741c 100644 --- a/src/SMAPI/Framework/Models/ModDataRecord.cs +++ b/src/SMAPI/Framework/Models/ModDataRecord.cs @@ -1,49 +1,188 @@ +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.Serialization; using Newtonsoft.Json; -using StardewModdingAPI.Framework.Serialisation.SmapiConverters; +using Newtonsoft.Json.Linq; namespace StardewModdingAPI.Framework.Models { - /// Metadata about a mod from SMAPI's internal data. + /// Raw mod metadata from SMAPI's internal mod list. internal class ModDataRecord { /********* - ** Accessors + ** Properties *********/ - /// The unique mod identifier. - [JsonConverter(typeof(ModDataIdConverter))] - public ModDataID ID { get; set; } + /// This field stores properties that aren't mapped to another field before they're parsed into . + [JsonExtensionData] + private IDictionary ExtensionData; - /// A value to inject into field if it's not already set. - public string[] UpdateKeys { get; set; } - /// The URL where the player can get an unofficial or alternative version of the mod if the official version isn't compatible. - public string AlternativeUrl { get; set; } + /********* + ** Accessors + *********/ + /// The mod's current unique ID. + public string ID { get; set; } - /// The compatibility of given mod versions (if any). - [JsonConverter(typeof(ModCompatibilityArrayConverter))] - public ModCompatibility[] Compatibility { get; set; } = new ModCompatibility[0]; + /// The former mod IDs (if any). + /// + /// This uses a custom format which uniquely identifies a mod across multiple versions and + /// supports matching other fields if no ID was specified. This doesn't include the latest + /// ID, if any. Format rules: + /// 1. If the mod's ID changed over time, multiple variants can be separated by the + /// | character. + /// 2. Each variant can take one of two forms: + /// - A simple string matching the mod's UniqueID value. + /// - A JSON structure containing any of four manifest fields (ID, Name, Author, and + /// EntryDll) to match. + /// + public string FormerIDs { get; set; } - /// Map local versions to a semantic version for update checks. + /// Maps local versions to a semantic version for update checks. public IDictionary MapLocalVersions { get; set; } = new Dictionary(); - /// Map remote versions to a semantic version for update checks. + /// Maps remote versions to a semantic version for update checks. public IDictionary MapRemoteVersions { get; set; } = new Dictionary(); + /// The versioned field data. + /// + /// This maps field names to values. This should be accessed via . + /// Format notes: + /// - Each key consists of a field name prefixed with any combination of version range + /// and Default, separated by pipes (whitespace trimmed). For example, Name + /// will always override the name, Default | Name will only override a blank + /// name, and ~1.1 | Default | Name will override blank names up to version 1.1. + /// - The version format is min~max (where either side can be blank for unbounded), or + /// a single version number. + /// - The field name itself corresponds to a value. + /// + public IDictionary Fields { get; set; } = new Dictionary(); + /********* ** Public methods *********/ - /// Get the compatibility record for a given version, if any. - /// The mod version to check. - public ModCompatibility GetCompatibility(ISemanticVersion version) + /// Get whether the manifest matches the field. + /// The mod manifest to check. + public bool Matches(IManifest manifest) + { + // try main ID + if (this.ID != null && this.ID.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + return true; + + // try former IDs + if (this.FormerIDs != null) + { + foreach (string part in this.FormerIDs.Split('|')) + { + // packed field snapshot + if (part.StartsWith("{")) + { + FieldSnapshot snapshot = JsonConvert.DeserializeObject(part); + bool isMatch = + (snapshot.ID == null || snapshot.ID.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + && (snapshot.EntryDll == null || snapshot.EntryDll.Equals(manifest.EntryDll, StringComparison.InvariantCultureIgnoreCase)) + && ( + snapshot.Author == null + || snapshot.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase) + || (manifest.ExtraFields.ContainsKey("Authour") && snapshot.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase)) + ) + && (snapshot.Name == null || snapshot.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase)); + + if (isMatch) + return true; + } + + // plain ID + else if (part.Equals(manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + return true; + } + } + + // no match + return false; + } + + /// Get a parsed representation of the . + public IEnumerable GetFields() { - return this.Compatibility.FirstOrDefault(p => p.MatchesVersion(version)); + foreach (KeyValuePair pair in this.Fields) + { + // init fields + string packedKey = pair.Key; + string value = pair.Value; + bool isDefault = false; + ISemanticVersion lowerVersion = null; + ISemanticVersion upperVersion = null; + + // parse + string[] parts = packedKey.Split('|').Select(p => p.Trim()).ToArray(); + ModDataFieldKey fieldKey = (ModDataFieldKey)Enum.Parse(typeof(ModDataFieldKey), parts.Last(), ignoreCase: true); + foreach (string part in parts.Take(parts.Length - 1)) + { + // 'default' + if (part.Equals("Default", StringComparison.InvariantCultureIgnoreCase)) + { + isDefault = true; + continue; + } + + // version range + if (part.Contains("~")) + { + string[] versionParts = part.Split(new[] { '~' }, 2); + lowerVersion = versionParts[0] != "" ? new SemanticVersion(versionParts[0]) : null; + upperVersion = versionParts[1] != "" ? new SemanticVersion(versionParts[1]) : null; + continue; + } + + // single version + lowerVersion = new SemanticVersion(part); + upperVersion = new SemanticVersion(part); + } + + yield return new ModDataField(fieldKey, value, isDefault, lowerVersion, upperVersion); + } + } + + /// Get a parsed representation of the which match a given manifest. + /// The manifest to match. + public ParsedModDataRecord ParseFieldsFor(IManifest manifest) + { + ParsedModDataRecord parsed = new ParsedModDataRecord { DataRecord = this }; + foreach (ModDataField field in this.GetFields().Where(field => field.IsMatch(manifest))) + { + switch (field.Key) + { + // update key + case ModDataFieldKey.UpdateKey: + parsed.UpdateKey = field.Value; + break; + + // alternative URL + case ModDataFieldKey.AlternativeUrl: + parsed.AlternativeUrl = field.Value; + break; + + // status + case ModDataFieldKey.Status: + parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true); + parsed.StatusUpperVersion = field.UpperVersion; + break; + + // status reason phrase + case ModDataFieldKey.StatusReasonPhrase: + parsed.StatusReasonPhrase = field.Value; + break; + } + } + + return parsed; } /// Get a semantic local version for update checks. - /// The local version to normalise. + /// The remote version to normalise. public string GetLocalVersionForUpdateChecks(string version) { return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version, out string newVersion) @@ -59,5 +198,46 @@ namespace StardewModdingAPI.Framework.Models ? newVersion : version; } + + + /********* + ** Private methods + *********/ + /// The method invoked after JSON deserialisation. + /// The deserialisation context. + [OnDeserialized] + private void OnDeserialized(StreamingContext context) + { + if (this.ExtensionData != null) + { + this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString()); + this.ExtensionData = null; + } + } + + + /********* + ** Private models + *********/ + /// A unique set of fields which identifies the mod. + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "Used via JSON deserialisation.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used via JSON deserialisation.")] + private class FieldSnapshot + { + /********* + ** Accessors + *********/ + /// The unique mod ID (or null to ignore it). + public string ID { get; set; } + + /// The entry DLL (or null to ignore it). + public string EntryDll { get; set; } + + /// The mod name (or null to ignore it). + public string Name { get; set; } + + /// The author name (or null to ignore it). + public string Author { get; set; } + } } } diff --git a/src/SMAPI/Framework/Models/ParsedModDataRecord.cs b/src/SMAPI/Framework/Models/ParsedModDataRecord.cs new file mode 100644 index 00000000..0abc7b89 --- /dev/null +++ b/src/SMAPI/Framework/Models/ParsedModDataRecord.cs @@ -0,0 +1,48 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// A parsed representation of the fields from a for a specific manifest. + internal class ParsedModDataRecord + { + /********* + ** Accessors + *********/ + /// The underlying data record. + public ModDataRecord DataRecord { get; set; } + + /// The update key to apply. + public string UpdateKey { get; set; } + + /// The mod version to apply. + public ISemanticVersion Version { get; set; } + + /// The alternative URL the player can check for an updated version. + public string AlternativeUrl { get; set; } + + /// The predefined compatibility status. + public ModStatus Status { get; set; } = ModStatus.None; + + /// A reason phrase for the , or null to use the default reason. + public string StatusReasonPhrase { get; set; } + + /// The upper version for which the applies (if any). + public ISemanticVersion StatusUpperVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// Get a semantic local version for update checks. + /// The remote version to normalise. + public string GetLocalVersionForUpdateChecks(string version) + { + return this.DataRecord.GetLocalVersionForUpdateChecks(version); + } + + /// Get a semantic remote version for update checks. + /// The remote version to normalise. + public string GetRemoteVersionForUpdateChecks(string version) + { + return this.DataRecord.GetRemoteVersionForUpdateChecks(version); + } + } +} -- cgit