diff --git a/.editorconfig b/.editorconfig index 1991c31..f879977 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ [*] charset = utf-8 end_of_line = crlf -trim_trailing_whitespace = true +trim_trailing_whitespace = false insert_final_newline = false indent_style = space indent_size = 4 @@ -48,12 +48,6 @@ dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d dotnet_naming_rule.unity_serialized_field_rule.severity = warning dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols -dotnet_naming_rule.unity_serialized_field_rule_1.import_to_resharper = True -dotnet_naming_rule.unity_serialized_field_rule_1.resharper_description = Unity serialized field -dotnet_naming_rule.unity_serialized_field_rule_1.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef -dotnet_naming_rule.unity_serialized_field_rule_1.severity = warning -dotnet_naming_rule.unity_serialized_field_rule_1.style = lower_camel_case_style -dotnet_naming_rule.unity_serialized_field_rule_1.symbols = unity_serialized_field_symbols_1 dotnet_naming_style.lower_camel_case_style.capitalization = camel_case dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected @@ -79,10 +73,6 @@ dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance -dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols_1.applicable_kinds = -dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_applicable_kinds = unity_serialised_field -dotnet_naming_symbols.unity_serialized_field_symbols_1.resharper_required_modifiers = instance dotnet_separate_import_directive_groups = true dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:none dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none @@ -121,17 +111,12 @@ resharper_csharp_int_align_fix_in_adjacent = false resharper_csharp_keep_blank_lines_in_code = 1 resharper_csharp_keep_blank_lines_in_declarations = 1 resharper_csharp_max_line_length = 0 -resharper_csharp_naming_rule.constants = AaBb -resharper_csharp_naming_rule.private_constants = aaBb -resharper_csharp_naming_rule.private_static_fields = aaBb -resharper_csharp_naming_rule.private_static_readonly = aaBb -resharper_csharp_naming_rule.static_readonly = AaBb resharper_csharp_stick_comment = false resharper_csharp_wrap_arguments_style = chop_if_long resharper_csharp_wrap_before_binary_opsign = true resharper_csharp_wrap_extends_list_style = chop_if_long +resharper_csharp_wrap_lines = false resharper_csharp_wrap_parameters_style = chop_if_long -resharper_enforce_line_ending_style = true resharper_indent_nested_fixed_stmt = true resharper_indent_nested_foreach_stmt = true resharper_indent_nested_for_stmt = true diff --git a/LICENSE b/LICENSE index 98fd7d6..daadb22 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Carnagion +Copyright (c) 2022 Indraneel Mahendrakumar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Mod.cs b/Modding/Mod.cs similarity index 82% rename from Mod.cs rename to Modding/Mod.cs index 5dad554..2595910 100644 --- a/Mod.cs +++ b/Modding/Mod.cs @@ -7,6 +7,7 @@ using JetBrains.Annotations; +using Godot.Modding.Patching; using Godot.Serialization; namespace Godot.Modding @@ -24,9 +25,10 @@ public sealed record Mod public Mod(Metadata metadata) { this.Meta = metadata; - this.Assemblies = this.LoadAssemblies(); - this.Data = this.LoadData(); this.LoadResources(); + this.Data = this.LoadData(); + this.Patches = this.LoadPatches(); + this.Assemblies = this.LoadAssemblies(); } /// @@ -46,9 +48,17 @@ public IEnumerable Assemblies } /// - /// The XML data of the , combined into a single as its children. + /// The XML data of the if any, combined into a single . + /// + public XmlDocument? Data + { + get; + } + + /// + /// The patches applied by the . /// - public XmlNode? Data + public IEnumerable Patches { get; } @@ -62,7 +72,7 @@ private IEnumerable LoadAssemblies() : Enumerable.Empty(); } - private XmlNode? LoadData() + private XmlDocument? LoadData() { IEnumerable documents = this.LoadDocuments().ToArray(); if (!documents.Any()) @@ -71,11 +81,16 @@ private IEnumerable LoadAssemblies() } XmlDocument data = new(); - data.InsertBefore(data.CreateXmlDeclaration("1.0", "UTF-8", null), data.DocumentElement); + + XmlElement root = data.CreateElement("Data"); + data.AppendChild(root); + data.InsertBefore(data.CreateXmlDeclaration("1.0", "UTF-8", null), root); + documents .SelectMany(document => document.Cast()) - .Where(node => node.NodeType is not XmlNodeType.XmlDeclaration) - .ForEach(node => data.AppendChild(node)); + .Where(node => node is not XmlDeclaration) + .ForEach(node => root.AppendChild(data.ImportNode(node, true))); + return data; } @@ -105,11 +120,30 @@ private void LoadResources() return; } - foreach (string resourcePath in System.IO.Directory.GetFiles(resourcesPath, "*.pck", SearchOption.AllDirectories)) + string? invalidResourcePath = System.IO.Directory.GetFiles(resourcesPath, "*.pck", SearchOption.AllDirectories).FirstOrDefault(resourcePath => !ProjectSettings.LoadResourcePack(resourcePath)); + if (invalidResourcePath is not null) + { + throw new ModLoadException(this.Meta.Directory, $"Error loading resource pack at {invalidResourcePath}"); + } + } + + private IEnumerable LoadPatches() + { + string patchesPath = $"{this.Meta.Directory}{System.IO.Path.DirectorySeparatorChar}Patches"; + + if (!System.IO.Directory.Exists(patchesPath)) + { + yield break; + } + + Serializer serializer = new(); + XmlDocument document = new(); + foreach (string patchPath in System.IO.Directory.GetFiles(patchesPath, "*.xml", SearchOption.AllDirectories)) { - if (!ProjectSettings.LoadResourcePack(resourcePath)) + document.Load(patchPath); + if (document.DocumentElement is not null) { - throw new ModLoadException(this.Meta.Directory, $"Error loading resource pack at {resourcePath}"); + yield return serializer.Deserialize(document.DocumentElement) as IPatch ?? throw new ModLoadException(this.Meta.Directory, $"Invalid patch at {patchPath}"); } } } diff --git a/ModLoadException.cs b/Modding/ModLoadException.cs similarity index 100% rename from ModLoadException.cs rename to Modding/ModLoadException.cs diff --git a/ModLoader.cs b/Modding/ModLoader.cs similarity index 76% rename from ModLoader.cs rename to Modding/ModLoader.cs index 624f337..f2a2c18 100644 --- a/ModLoader.cs +++ b/Modding/ModLoader.cs @@ -1,10 +1,12 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Xml; using JetBrains.Annotations; -using Godot.Modding.Utility.Extensions; +using Godot.Utility; +using Godot.Utility.Extensions; namespace Godot.Modding { @@ -14,7 +16,7 @@ namespace Godot.Modding [PublicAPI] public static class ModLoader { - private static readonly Dictionary loadedMods = new(); + private static readonly OrderedDictionary loadedMods = new(); /// /// All the s that have been loaded at runtime. @@ -28,7 +30,7 @@ public static IReadOnlyDictionary LoadedMods } /// - /// Loads a from and runs all methods marked with in its assemblies if specified. + /// Loads a from , applies its patches if any, and runs all methods marked with in its assemblies if specified. /// /// The directory path containing the 's metadata, assemblies, data, and resource packs. /// Whether any code in any assemblies of the loaded gets executed. @@ -36,17 +38,33 @@ public static IReadOnlyDictionary LoadedMods /// This method only loads a individually, and does not check whether it has been loaded with all dependencies and in the correct load order. To load multiple s in a safe and orderly manner, should be used. public static Mod LoadMod(string modDirectoryPath, bool executeAssemblies = true) { + // Load mod Mod mod = new(Mod.Metadata.Load(modDirectoryPath)); - ModLoader.loadedMods.Add(mod.Meta.Id, mod); + + // Cache XML data of loaded mods for repeat enumeration later + XmlElement[] data = ModLoader.LoadedMods.Values + .Select(loadedMod => loadedMod.Data?.DocumentElement) + .Append(mod.Data?.DocumentElement) + .NotNull() + .ToArray(); + + // Apply mod patches + mod.Patches.ForEach(patch => data.ForEach(patch.Apply)); + + // Execute mod assemblies if (executeAssemblies) { ModLoader.StartupMod(mod); } + + // Register mod as fully loaded + ModLoader.loadedMods.Add(mod.Meta.Id, mod); + return mod; } /// - /// Loads s from and runs all methods marked with in their assemblies if specified. + /// Loads s from , applies their patches if any, runs all methods marked with in their assemblies if specified. /// /// The directory paths to load the s from, containing each 's metadata, assemblies, data, and resource packs. /// Whether any code in any assemblies of the loaded s gets executed. @@ -54,17 +72,41 @@ public static Mod LoadMod(string modDirectoryPath, bool executeAssemblies = true /// This method loads multiple s after sorting them according to the load order specified in their metadata. To load a individually without regard to its dependencies and load order, should be used. public static IEnumerable LoadMods(IEnumerable modDirectoryPaths, bool executeAssemblies = true) { - List mods = ModLoader.SortModMetadata(ModLoader.FilterModMetadata(ModLoader.LoadModMetadata(modDirectoryPaths))) - .Select(metadata => new Mod(metadata)) + // Cache XML data of loaded mods for repeat enumeration later + List data = ModLoader.LoadedMods.Values + .Select(mod => mod.Data?.DocumentElement) + .NotNull() .ToList(); - mods.ForEach(mod => ModLoader.loadedMods.Add(mod.Meta.Id, mod)); - if (executeAssemblies) + + List mods = new(); + foreach (Mod.Metadata metadata in ModLoader.SortModMetadata(ModLoader.FilterModMetadata(ModLoader.LoadModMetadata(modDirectoryPaths)))) { - mods.ForEach(ModLoader.StartupMod); + // Load mod + Mod mod = new(metadata); + mods.Add(mod); + + // Apply mod patches + XmlElement? root = mod.Data?.DocumentElement; + if (root is not null) + { + data.Add(root); + } + mod.Patches.ForEach(patch => data.ForEach(patch.Apply)); + } + foreach (Mod mod in mods) + { + // Execute mod assemblies + if (executeAssemblies) + { + ModLoader.StartupMod(mod); + } + + // Register mod as fully loaded + ModLoader.loadedMods.Add(mod.Meta.Id, mod); } return mods; } - + private static void StartupMod(Mod mod) { // Invoke all static methods annotated with [Startup] along with the supplied parameters (if any) diff --git a/ModStartupAttribute.cs b/Modding/ModStartupAttribute.cs similarity index 100% rename from ModStartupAttribute.cs rename to Modding/ModStartupAttribute.cs diff --git a/Modding/Patching/AttributeRemovePatch.cs b/Modding/Patching/AttributeRemovePatch.cs new file mode 100644 index 0000000..3e3f7c0 --- /dev/null +++ b/Modding/Patching/AttributeRemovePatch.cs @@ -0,0 +1,52 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that removes an attribute from an . + /// + [PublicAPI] + public class AttributeRemovePatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The name of the attribute to remove. + public AttributeRemovePatch(string attribute) + { + this.Attribute = attribute; + } + + [UsedImplicitly] + private AttributeRemovePatch() + { + } + + /// + /// The name of the attribute to remove. + /// + [Serialize] + public string Attribute + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Removes from if is an . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + if (data is XmlElement element) + { + element.RemoveAttribute(this.Attribute); + } + } + } +} \ No newline at end of file diff --git a/Modding/Patching/AttributeSetPatch.cs b/Modding/Patching/AttributeSetPatch.cs new file mode 100644 index 0000000..44784cc --- /dev/null +++ b/Modding/Patching/AttributeSetPatch.cs @@ -0,0 +1,65 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that sets the value of an attribute in an . + /// + [PublicAPI] + public class AttributeSetPatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The name of the attribute to add/set. + /// The value of the attribute to add/set. + public AttributeSetPatch(string attribute, string value) + { + this.Attribute = attribute; + this.Value = value; + } + + [UsedImplicitly] + private AttributeSetPatch() + { + } + + /// + /// The name of the attribute to add/set. + /// + [Serialize] + public string Attribute + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// The value of the attribute to add/set. + /// + [Serialize] + public string Value + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Sets to on if is an . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + if (data is XmlElement element) + { + element.SetAttribute(this.Attribute, this.Value); + } + } + } +} \ No newline at end of file diff --git a/Modding/Patching/ConditionalPatch.cs b/Modding/Patching/ConditionalPatch.cs new file mode 100644 index 0000000..60157ff --- /dev/null +++ b/Modding/Patching/ConditionalPatch.cs @@ -0,0 +1,75 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Modding.Patching.Conditions; +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that can apply either a "success" or a "failure" patch to an depending on a condition. + /// + [PublicAPI] + public class ConditionalPatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The condition to check. + /// The patch to apply if succeeds. + /// The patch to apply if fails. + public ConditionalPatch(ICondition condition, IPatch? success, IPatch? failure) + { + this.Condition = condition; + this.Success = success; + this.Failure = failure; + } + + [UsedImplicitly] + private ConditionalPatch() + { + } + + /// + /// The condition to check when applying the . + /// + [Serialize] + public ICondition Condition + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// The applied if succeeds. + /// + public IPatch? Success + { + get; + [UsedImplicitly] + private set; + } + + /// + /// The applied if fails. + /// + public IPatch? Failure + { + get; + [UsedImplicitly] + private set; + } + + /// + /// Applies either or to depending on . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + IPatch? patch = this.Condition.Check(data) ? this.Success : this.Failure; + patch?.Apply(data); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/Conditions/AndCondition.cs b/Modding/Patching/Conditions/AndCondition.cs new file mode 100644 index 0000000..a99c9b7 --- /dev/null +++ b/Modding/Patching/Conditions/AndCondition.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching.Conditions +{ + /// + /// An that succeeds if all of a specified sequence of conditions succeed. + /// + [PublicAPI] + public class AndCondition : ICondition + { + /// + /// Initialises a new with the specified parameters. + /// + /// The conditions to check. + public AndCondition(IEnumerable conditions) + { + this.Conditions = conditions; + } + + [UsedImplicitly] + private AndCondition() + { + } + + /// + /// The conditions to check. + /// + [Serialize] + public IEnumerable Conditions + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Succeeds if all conditions in succeed. + /// + /// The to apply the patch on. + /// if all conditions in succeed, else . + public bool Check(XmlNode data) + { + return this.Conditions.All(condition => condition.Check(data)); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/Conditions/ICondition.cs b/Modding/Patching/Conditions/ICondition.cs new file mode 100644 index 0000000..e1aa214 --- /dev/null +++ b/Modding/Patching/Conditions/ICondition.cs @@ -0,0 +1,17 @@ +using System.Xml; + +namespace Godot.Modding.Patching.Conditions +{ + /// + /// Represents a condition that can be tested on an . + /// + public interface ICondition + { + /// + /// Checks if a 's XML data satisfies the . + /// + /// The to apply the patch on. + /// if the condition succeeds, else . + public bool Check(XmlNode data); + } +} \ No newline at end of file diff --git a/Modding/Patching/Conditions/ModLoadedCondition.cs b/Modding/Patching/Conditions/ModLoadedCondition.cs new file mode 100644 index 0000000..f29c7b7 --- /dev/null +++ b/Modding/Patching/Conditions/ModLoadedCondition.cs @@ -0,0 +1,50 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching.Conditions +{ + /// + /// An that checks if a particular has been loaded. + /// + [PublicAPI] + public class ModLoadedCondition : ICondition + { + /// + /// Initialises a new with the specified parameters. + /// + /// The ID of the to check for. + public ModLoadedCondition(string modId) + { + this.ModId = modId; + } + + [UsedImplicitly] + private ModLoadedCondition() + { + } + + /// + /// The ID of the to check for. + /// + [Serialize] + public string ModId + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Checks if any loaded 's ID equals . + /// + /// The to apply the patch on. This is not used by the . + /// if the given by is loaded, else . + public bool Check(XmlNode data) + { + return ModLoader.LoadedMods.TryGetValue(this.ModId, out _); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/Conditions/NodeExistsCondition.cs b/Modding/Patching/Conditions/NodeExistsCondition.cs new file mode 100644 index 0000000..6e5b483 --- /dev/null +++ b/Modding/Patching/Conditions/NodeExistsCondition.cs @@ -0,0 +1,50 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching.Conditions +{ + /// + /// An that checks if a descendant of an matching an XPath string exists. + /// + [PublicAPI] + public class NodeExistsCondition : ICondition + { + /// + /// Initialises a new with the specified parameters. + /// + /// The XPath string to check. + public NodeExistsCondition(string xPath) + { + this.XPath = xPath; + } + + [UsedImplicitly] + private NodeExistsCondition() + { + } + + /// + /// The XPath string to use when checking if an exists. + /// + [Serialize] + public string XPath + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Checks if any s match the XPath given by . + /// + /// The to apply the patch on. + /// if selects at least one , else . + public bool Check(XmlNode data) + { + return data.SelectSingleNode(this.XPath) is not null; + } + } +} \ No newline at end of file diff --git a/Modding/Patching/Conditions/NotCondition.cs b/Modding/Patching/Conditions/NotCondition.cs new file mode 100644 index 0000000..9c34630 --- /dev/null +++ b/Modding/Patching/Conditions/NotCondition.cs @@ -0,0 +1,50 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching.Conditions +{ + /// + /// An that succeeds if a specified condition fails. + /// + [PublicAPI] + public class NotCondition : ICondition + { + /// + /// Initialises a new with the specified parameters. + /// + /// The to check. + public NotCondition(ICondition condition) + { + this.Condition = condition; + } + + [UsedImplicitly] + private NotCondition() + { + } + + /// + /// The condition to check. + /// + [Serialize] + public ICondition Condition + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Succeeds if fails. + /// + /// The to apply the patch on. + /// if fails, else . + public bool Check(XmlNode data) + { + return !this.Condition.Check(data); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/Conditions/OrCondition.cs b/Modding/Patching/Conditions/OrCondition.cs new file mode 100644 index 0000000..ac1ed10 --- /dev/null +++ b/Modding/Patching/Conditions/OrCondition.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching.Conditions +{ + /// + /// An that succeeds if at least one of a specified sequence of conditions succeed. + /// + [PublicAPI] + public class OrCondition : ICondition + { + /// + /// Initialises a new with the specified parameters. + /// + /// The conditions to check. + public OrCondition(IEnumerable conditions) + { + this.Conditions = conditions; + } + + [UsedImplicitly] + private OrCondition() + { + } + + /// + /// The conditions to check. + /// + public IEnumerable Conditions + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Succeeds if at least one condition in succeeds. + /// + /// The to apply the patch on. + /// if at least one condition in succeeds, else . + public bool Check(XmlNode data) + { + return this.Conditions.Any(condition => condition.Check(data)); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/IPatch.cs b/Modding/Patching/IPatch.cs new file mode 100644 index 0000000..794087e --- /dev/null +++ b/Modding/Patching/IPatch.cs @@ -0,0 +1,16 @@ +using System.Xml; + +namespace Godot.Modding.Patching +{ + /// + /// Represents a modification that can be applied to the XML data of a . + /// + public interface IPatch + { + /// + /// Applies the patch to . + /// + /// The to apply the patch on. + public void Apply(XmlNode data); + } +} \ No newline at end of file diff --git a/Modding/Patching/LogPatch.cs b/Modding/Patching/LogPatch.cs new file mode 100644 index 0000000..c8b8fe6 --- /dev/null +++ b/Modding/Patching/LogPatch.cs @@ -0,0 +1,50 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that logs the state of any s before and after applying a separate patch to them. + /// + [PublicAPI] + public class LogPatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The patch to apply before and after logging the . + public LogPatch(IPatch patch) + { + this.Patch = patch; + } + + private LogPatch() + { + } + + /// + /// The patch to apply before and after logging the . + /// + [Serialize] + public IPatch Patch + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Logs the XML string representation of before and after applying to it. + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + Log.Write($"Before: {data.OuterXml}"); + this.Patch.Apply(data); + Log.Write($"After: {data.OuterXml}"); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/MultiPatch.cs b/Modding/Patching/MultiPatch.cs new file mode 100644 index 0000000..911613c --- /dev/null +++ b/Modding/Patching/MultiPatch.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that applies multiple patches in sequence onto the same . + /// + [PublicAPI] + public class MultiPatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The patches to apply in sequence. + public MultiPatch(IEnumerable patches) + { + this.Patches = patches; + } + + [UsedImplicitly] + private MultiPatch() + { + } + + /// + /// The patches to apply in sequence. + /// + [Serialize] + public IEnumerable Patches + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Applies all patches in to . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + this.Patches.ForEach(patch => patch.Apply(data)); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/NodeAddPatch.cs b/Modding/Patching/NodeAddPatch.cs new file mode 100644 index 0000000..9734233 --- /dev/null +++ b/Modding/Patching/NodeAddPatch.cs @@ -0,0 +1,73 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that adds an as a child to another . + /// + [PublicAPI] + public class NodeAddPatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The to add as a child. + /// The index to insert at, or -1 if it should simply be appended to the end. + public NodeAddPatch(XmlNode value, int index = -1) + { + this.Value = value; + this.Index = index; + } + + [UsedImplicitly] + private NodeAddPatch() + { + } + + /// + /// The to add as a child. + /// + [Serialize] + public XmlNode Value + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// The index to insert at. + /// + public int Index + { + get; + [UsedImplicitly] + private set; + } = -1; + + /// + /// Adds as a child to at the index specified by . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + XmlNode value = data.OwnerDocument!.ImportNode(this.Value, true); + switch (this.Index) + { + case < 0: + data.AppendChild(value); + break; + case 0: + data.PrependChild(value); + break; + default: + data.InsertBefore(value, data.ChildNodes[this.Index]); + break; + } + } + } +} \ No newline at end of file diff --git a/Modding/Patching/NodeRemovePatch.cs b/Modding/Patching/NodeRemovePatch.cs new file mode 100644 index 0000000..00c95ab --- /dev/null +++ b/Modding/Patching/NodeRemovePatch.cs @@ -0,0 +1,22 @@ +using System.Xml; + +using JetBrains.Annotations; + +namespace Godot.Modding.Patching +{ + /// + /// An that removes the it is applied on. + /// + [PublicAPI] + public class NodeRemovePatch : IPatch + { + /// + /// Removes from its parent . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + data.ParentNode!.RemoveChild(data); + } + } +} \ No newline at end of file diff --git a/Modding/Patching/NodeReplacePatch.cs b/Modding/Patching/NodeReplacePatch.cs new file mode 100644 index 0000000..fa9b390 --- /dev/null +++ b/Modding/Patching/NodeReplacePatch.cs @@ -0,0 +1,59 @@ +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that replaces an with another one. + /// + [PublicAPI] + public class NodeReplacePatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// The to add in place of the removed . + public NodeReplacePatch(XmlNode replacement) + { + this.Replacement = replacement; + } + + [UsedImplicitly] + private NodeReplacePatch() + { + } + + /// + /// The to add in place of the removed . + /// + [Serialize] + public XmlNode Replacement + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Removes and replaces it with . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + XmlNode? previous = data.PreviousSibling; + XmlNode parent = data.ParentNode!; + parent.RemoveChild(data); + if (previous is null) + { + parent.PrependChild(this.Replacement); + } + else + { + parent.InsertAfter(this.Replacement, previous); + } + } + } +} \ No newline at end of file diff --git a/Modding/Patching/TargetedPatch.cs b/Modding/Patching/TargetedPatch.cs new file mode 100644 index 0000000..8cc0ea4 --- /dev/null +++ b/Modding/Patching/TargetedPatch.cs @@ -0,0 +1,65 @@ +using System.Linq; +using System.Xml; + +using JetBrains.Annotations; + +using Godot.Serialization; + +namespace Godot.Modding.Patching +{ + /// + /// An that selects descendants of an according to an XPath string and applies a separate patch on them. + /// + [PublicAPI] + public class TargetedPatch : IPatch + { + /// + /// Initialises a new with the specified parameters. + /// + /// An XPath string that specifies descendant s to apply on. + /// The patch to apply on all s selected by . + public TargetedPatch(string targets, IPatch patch) + { + this.Targets = targets; + this.Patch = patch; + } + + [UsedImplicitly] + private TargetedPatch() + { + } + + /// + /// The targets to apply the on, in the form of an XPath. + /// + [Serialize] + public string Targets + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// The patch to apply on s that match . + /// + [Serialize] + public IPatch Patch + { + get; + [UsedImplicitly] + private set; + } = null!; + + /// + /// Applies to all s under that match . + /// + /// The to apply the patch on. + public void Apply(XmlNode data) + { + data.SelectNodes(this.Targets)? + .Cast() + .ForEach(this.Patch.Apply); + } + } +} \ No newline at end of file diff --git a/Modot.csproj b/Modot.csproj index 1a5d4c5..609809c 100644 --- a/Modot.csproj +++ b/Modot.csproj @@ -3,12 +3,12 @@ default enable netstandard2.1 - Godot.Modding + Godot true true true - 1.0.2 + 2.0.0 Modot Carnagion A mod loader and API for applications made using Godot, with the ability to load C# assemblies, XML data, and resource packs at runtime. @@ -17,7 +17,7 @@ - + diff --git a/README.md b/README.md index 7683112..e2a2608 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Its API is aimed at allowing creators to easily modularise their Godot applicati - Load mods with C# assemblies, XML data, and resource packs at runtime - Sort mods using load orders defined partially by each mod to prevent conflicts +- Patch XML data of other loaded mods without executing code - Optionally execute code from mod assemblies upon loading - Load mods individually, bypassing load order restrictions @@ -19,7 +20,7 @@ A more detailed explanation of all features along with instructions on usage is Simply include the following lines in a Godot project's `.csproj` file (either by editing the file manually or letting an IDE install the package): ```xml - + ``` @@ -42,4 +43,4 @@ As such, it is important to note that **Modot does not bear the responsibility o However, it does provide the option to ignore a mod's assemblies, preventing any code from being executed. Along with the ability to load mods individually, this can be used to ensure that only trusted mods can execute their code. -Another way to prevent executing malicious code is by restricting the source of mods to websites that thoroughly scan and verify uploaded user content, such as [Steam](https://store.steampowered.com). As mentioned earlier though, **it is not Modot's responsibility to implement such checks**. \ No newline at end of file +Another way to prevent executing malicious code is by restricting the source of mods to websites that thoroughly scan and verify uploaded user content. As mentioned earlier though, **it is not Modot's responsibility to implement such checks**. \ No newline at end of file diff --git a/Utility/ErrorException.cs b/Utility/ErrorException.cs new file mode 100644 index 0000000..46743e1 --- /dev/null +++ b/Utility/ErrorException.cs @@ -0,0 +1,30 @@ +using System; + +using JetBrains.Annotations; + +namespace Godot.Utility +{ + /// + /// A wrapper around to make it throwable. + /// + [PublicAPI] + public class ErrorException : Exception + { + /// + /// Initialises a new with the specified . + /// + /// The Godot . + public ErrorException(Error error) : base(error.ToString()) + { + this.Error = error; + } + + /// + /// The Godot . + /// + public Error Error + { + get; + } + } +} \ No newline at end of file diff --git a/Utility/Extensions/DirectoryExtensions.cs b/Utility/Extensions/DirectoryExtensions.cs index 5ed7fb9..bb77db6 100644 --- a/Utility/Extensions/DirectoryExtensions.cs +++ b/Utility/Extensions/DirectoryExtensions.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using JetBrains.Annotations; -namespace Godot.Modding.Utility.Extensions +namespace Godot.Utility.Extensions { /// /// Contains extension methods for . @@ -20,42 +21,11 @@ public static class DirectoryExtensions /// The source directory path. It can be an absolute path, or relative to . /// The destination directory path. It can be an absolute path, or relative to . /// Whether the contents should be copied recursively (i.e. copy files inside subdirectories and so on) or not. - public static void CopyContents(this Directory directory, string from, string to, bool recursive = false) + /// An array of the paths of all files that were copied from to . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string[] CopyContents(this Directory directory, string from, string to, bool recursive = false) { - directory.Open(from); - - // Create destination directory if it doesn't already exist - directory.MakeDirRecursive(to); - - // Regex is used to replace only the first instance of the destination directory in file and subdirectory paths (string.Replace() replaces all instances) - Regex fromReplacement = new(Regex.Escape(from)); - - // Copy all files inside the source directory non-recursively - foreach (string fromFile in directory.GetFiles()) - { - string toFile = fromReplacement.Replace(fromFile, to, 1); - directory.Copy(fromFile, toFile); - } - - if (!recursive) - { - return; - } - - // Copy all files recursively - foreach (string fromSubDirectory in directory.GetDirectories(true)) - { - string toSubDirectory = fromReplacement.Replace(fromSubDirectory, to, 1); - directory.MakeDirRecursive(toSubDirectory); - - using Directory innerDirectory = new(); - innerDirectory.Open(fromSubDirectory); - foreach (string fromFile in innerDirectory.GetFiles()) - { - string toFile = fromReplacement.Replace(fromFile, to, 1); - directory.Copy(fromFile, toFile); - } - } + return directory.CopyContentsLazy(from, to, recursive).ToArray(); } /// @@ -73,7 +43,7 @@ public static string[] GetFiles(this Directory directory, bool recursive = false .SelectMany(path => { using Directory recursiveDirectory = new(); - recursiveDirectory.Open(path); + recursiveDirectory.Open(path).Throw(); return recursiveDirectory.GetElementsNonRecursive(true); }) .Concat(directory.GetElementsNonRecursive(true)) @@ -97,7 +67,7 @@ public static string[] GetFiles(this Directory directory, bool recursive = false ? Array.FindAll(directory.GetFiles(recursive), file => fileExtensions.Any(file.EndsWith)) : directory.GetFiles(recursive); } - + /// /// Returns the complete directory paths of all directories inside . /// @@ -113,7 +83,7 @@ public static string[] GetDirectories(this Directory directory, bool recursive = .SelectMany(path => { using Directory recursiveDirectory = new(); - recursiveDirectory.Open(path); + recursiveDirectory.Open(path).Throw(); return recursiveDirectory .GetDirectories(true) .Prepend(path); @@ -124,10 +94,9 @@ public static string[] GetDirectories(this Directory directory, bool recursive = .ToArray(); } - [MustUseReturnValue] private static IEnumerable GetElementsNonRecursive(this Directory directory, bool trueIfFiles) { - directory.ListDirBegin(true); + directory.ListDirBegin(true).Throw(); while (true) { string next = directory.GetNext(); @@ -135,6 +104,7 @@ private static IEnumerable GetElementsNonRecursive(this Directory direct { yield break; } + // Continue if the current element is a file or directory depending on which one is being queried if (directory.CurrentIsDir() == trueIfFiles) { continue; @@ -143,5 +113,45 @@ private static IEnumerable GetElementsNonRecursive(this Directory direct yield return current.EndsWith("/") ? $"{current}{next}" : $"{current}/{next}"; } } + + private static IEnumerable CopyContentsLazy(this Directory directory, string from, string to, bool recursive = false) + { + directory.Open(from).Throw(); + + // Create destination directory if it doesn't already exist + directory.MakeDirRecursive(to).Throw(); + + // Replace only the first instance of the destination directory in file and subdirectory paths using regex (string.Replace() replaces all instances) + Regex fromReplacement = new(Regex.Escape(from)); + + // Copy all files inside the source directory non-recursively + foreach (string fromFile in directory.GetElementsNonRecursive(true)) + { + string toFile = fromReplacement.Replace(fromFile, to, 1); + directory.Copy(fromFile, toFile).Throw(); + yield return toFile; + } + + if (!recursive) + { + yield break; + } + + // Copy all files recursively + foreach (string fromSubDirectory in directory.GetDirectories(true)) + { + string toSubDirectory = fromReplacement.Replace(fromSubDirectory, to, 1); + directory.MakeDirRecursive(toSubDirectory).Throw(); + + using Directory innerDirectory = new(); + innerDirectory.Open(fromSubDirectory).Throw(); + foreach (string fromFile in innerDirectory.GetElementsNonRecursive(true)) + { + string toFile = fromReplacement.Replace(fromFile, to, 1); + directory.Copy(fromFile, toFile).Throw(); + yield return toFile; + } + } + } } } \ No newline at end of file diff --git a/Utility/Extensions/EnumerableExtensions.cs b/Utility/Extensions/EnumerableExtensions.cs index 4a29a2a..58fd6b4 100644 --- a/Utility/Extensions/EnumerableExtensions.cs +++ b/Utility/Extensions/EnumerableExtensions.cs @@ -4,7 +4,7 @@ using JetBrains.Annotations; -namespace Godot.Modding.Utility.Extensions +namespace Godot.Utility.Extensions { /// /// Contains extension methods for . diff --git a/Utility/Extensions/ErrorExtensions.cs b/Utility/Extensions/ErrorExtensions.cs new file mode 100644 index 0000000..43446f7 --- /dev/null +++ b/Utility/Extensions/ErrorExtensions.cs @@ -0,0 +1,39 @@ +using System.Runtime.CompilerServices; + +using JetBrains.Annotations; + +namespace Godot.Utility.Extensions +{ + /// + /// Contains extension methods for . + /// + [PublicAPI] + public static class ErrorExtensions + { + /// + /// Checks if indicates success. + /// + /// The to check. + /// if is , else . + [Pure] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Success(this Error error) + { + return error is Error.Ok; + } + + /// + /// Throws an exception if indicates failure. + /// + /// The to check. + /// Thrown if is not . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Throw(this Error error) + { + if (error is not Error.Ok) + { + throw new ErrorException(error); + } + } + } +} \ No newline at end of file