Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a6b5339
Import node when using replacement patch
Aug 19, 2022
67d2a2c
Refactor C# project into separate directory
Sep 21, 2022
73b8804
Remove test mod files
Sep 21, 2022
b97c29e
Track files
Sep 21, 2022
69a6ff3
Merge branch 'stable' of https://github.com/Carnagion/Modot into stable
Sep 21, 2022
e8c64db
Create GDScript branch
Sep 21, 2022
07c4f73
Update gitignore
Sep 21, 2022
298967b
Add mod and mod loader classes
Sep 22, 2022
3673b92
Add utility classes
Sep 22, 2022
6e208ec
Update gitignore
Sep 23, 2022
c18dcb5
Update gitignore
Sep 23, 2022
1e3ad02
Improve error handling when loading mod
Sep 23, 2022
06d1bca
Add GDScript function to load multiple mods
Sep 23, 2022
85d3400
Add support for executing mod scripts on loading
Sep 23, 2022
7df7c7c
Fix bracket
Sep 25, 2022
7e29fbb
Improve string representation for mods
Sep 25, 2022
8011e1f
Refactor code and remove global ModLoader name
Sep 29, 2022
e0e51ab
Add documentation comments
Oct 1, 2022
619f020
Update gitignore
Oct 1, 2022
c33eef3
Update gitignore
Oct 1, 2022
e9bd8a3
Update csproj
Oct 1, 2022
5437977
Update README
Oct 1, 2022
2b680a4
Update csproj and README
Oct 1, 2022
dd99dbf
Update README
Oct 1, 2022
fd720f1
Update README
Oct 1, 2022
bcc17de
Update README
Oct 1, 2022
1f07533
Tweak alignment in README
Oct 1, 2022
0e7d81e
Tweak README
Oct 12, 2022
ffd3008
Add documentation comment
Nov 5, 2022
a8c12ab
Free mod script after running code
Nov 5, 2022
5c1ddb4
Update README
Nov 5, 2022
4186375
Update csproj
Nov 5, 2022
b0b3b01
Add method for copying directory contents
Nov 6, 2022
b066d95
Merge pull request #8 from Carnagion/gdscript
Carnagion Nov 7, 2022
868d18a
Merge pull request #9 from Carnagion/csharp
Carnagion Nov 7, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
bin/
obj/
*.sln
*.Dotsettings.user

*.godot
.mono/
.import/

.idea/
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,15 @@ public void Apply(XmlNode data)
{
XmlNode? previous = data.PreviousSibling;
XmlNode parent = data.ParentNode!;
XmlNode replacement = data.OwnerDocument!.ImportNode(this.Replacement, true);
parent.RemoveChild(data);
if (previous is null)
{
parent.PrependChild(this.Replacement);
parent.PrependChild(replacement);
}
else
{
parent.InsertAfter(this.Replacement, previous);
parent.InsertAfter(replacement, previous);
}
}
}
Expand Down
File renamed without changes.
12 changes: 6 additions & 6 deletions Modot.csproj → C#/Modot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@
<!-- Workaround as Godot does not know how to properly load NuGet packages -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageVersion>2.0.2</PackageVersion>
<PackageVersion>2.0.3</PackageVersion>
<Title>Modot</Title>
<Authors>Carnagion</Authors>
<Description>A mod loader and API for applications made using Godot, with the ability to load C# assemblies, XML data, and resource packs at runtime.</Description>
<RepositoryUrl>https://github.com/Carnagion/Modot</RepositoryUrl>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageLicenseUrl>../LICENSE</PackageLicenseUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GDLogger" Version="1.0.1"/>
<PackageReference Include="GDSerializer" Version="2.0.3"/>
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0"/>
</ItemGroup>
<ItemGroup>
<Content Include=".gitignore"/>
<Content Include="LICENSE"/>
<Content Include="README.md"/>
<Content Include="../.gitignore"/>
<Content Include="../LICENSE"/>
<Content Include="../README.md"/>
</ItemGroup>
<ItemGroup>
<None Include="LICENSE" Pack="true" PackagePath=""/>
<None Include="../LICENSE" Pack="true" PackagePath=""/>
</ItemGroup>
</Project>
File renamed without changes.
210 changes: 210 additions & 0 deletions GDScript/modding/mod.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
## Represents a modular component loaded at runtime, with its own scripts, resource packs, and data.
class_name Mod
extends RefCounted

## Initializes a new [Mod] using the [code]metadata[/code].
func _init(metadata):
self._meta = metadata
self._load_resources()
self._load_data()
self._load_scripts()

var _meta

var _data = {}

var _scripts = []

## The metadata of the [Mod], such as its ID, name, load order, etc.
var meta:
get:
return self._meta

## The JSON data of the [Mod], combined into a single JSON dictionary with the file names as keys and their parsed contents as values.
var data:
get:
return self._data

## The scripts of the [Mod].
var scripts:
get:
return self._scripts

func _load_resources():
var resources_path = self.meta.directory.path_join("resources")
var directory = Directory.new()
if not directory.dir_exists(resources_path):
return
directory.open(resources_path)
for unloaded_resource in DirectoryExtensions.get_files_recursive_ending(directory, ["pck"]).filter(func(resource_path): return not ProjectSettings.load_resource_pack(resource_path)):
Errors._mod_load_error(self.meta.directory, "Could not load resource pack at %s" % unloaded_resource)

func _load_data():
var data_path = self.meta.directory.path_join("data")
var directory = Directory.new()
if not directory.dir_exists(data_path):
return
directory.open(data_path)
var file = File.new()
for json_path in DirectoryExtensions.get_files_recursive_ending(directory, ["json"]):
file.open(json_path, File.READ)
var json = JSON.parse_string(file.get_as_text())
file.close()
if json == null:
Errors._mod_load_error(self.meta.directory, "Could not parse JSON at %s" % json_path)
continue
self._data[json_path] = json

func _load_scripts():
var scripts_path = self.meta.directory.path_join("scripts")
self._scripts.append_array(self._load_code(scripts_path))

func _load_code(directory_path):
var directory = Directory.new()
if not directory.dir_exists(directory_path):
return []
directory.open(directory_path)
var file = File.new()
return DirectoryExtensions.get_files_recursive_ending(directory, ["gd"]).map(func(file_path):
file.open(file_path, File.READ)
var code = file.get_as_text()
file.close()
var script = GDScript.new()
script.source_code = code
return script)

func _to_string():
return "{ meta: %s, data: %s, scripts: %s }" % [self.meta, self.data, self.scripts]

## Represents the metadata of a [Mod], such as its unique ID, name, author, load order, etc.
class Metadata extends RefCounted:

var _directory

var _id

var _name

var _author

var _dependencies

var _before

var _after

var _incompatible

## The directory where the [Metadata] was loaded from.
var directory:
get:
return self._directory

## The unique ID of the [Mod].
var id:
get:
return self._id

## THe name of the [Mod].
var name:
get:
return self._name

## The individual or group that created the [Mod].
var author:
get:
return self._author

## The unique IDs of all other [Mod]s that the [Mod] depends on.
var dependencies:
get:
return self._dependencies

## The unique IDs of all other [Mod]s that should be loaded before the [Mod].
var before:
get:
return self._before

## The unique IDs of all other [Mod]s that should be loaded after the [Mod].
var after:
get:
return self._after

## The unique IDs of all other [Mod]s that are incompatible with the [Mod].
var incompatible:
get:
return self._incompatible

static func _load(directory_path):
# Locate metadata file
var metadata_file_path = directory_path.path_join("mod.json")
var file = File.new()
if not file.file_exists(metadata_file_path):
Errors._mod_load_error(directory_path, "Mod metadata file does not exist")
return null
# Retrieve metadata file contents
file.open(metadata_file_path, File.READ)
var json = JSON.parse_string(file.get_as_text())
file.close()
if not json is Dictionary:
Errors._mod_load_error(directory_path, "Mod metadata is invalid")
return null
var meta = Mod.Metadata.new()
meta._directory = directory_path
return meta if meta._try_deserialize(json) and meta._is_valid() else null

func _try_deserialize(json):
# Retrieve compulsory metadata
var id = json.get("id")
var name = json.get("name")
var author = json.get("author")
if not (id is String and name is String and author is String):
Errors._mod_load_error(self.directory, "Mod metadata contains invalid ID, name, or author")
return false
self._id = id
self._name = name
self._author = author
# Retrieve optional metadata
var dependencies = json.get("dependencies", [])
if dependencies is Array:
self._dependencies = dependencies
else:
Errors._mod_load_error(self.directory, "Mod metadata contains invalid dependencies")
return false
var before = json.get("before", [])
if before is Array:
self._before = before
else:
Errors._mod_load_error(self.directory, "Mod metadata contains invalid load before list")
return false
var after = json.get("after", [])
if after is Array:
self._after = after
else:
Errors._mod_load_error(self.directory, "Mod metadata contains invalid load after list")
return false
var incompatible = json.get("incompatible", [])
if incompatible is Array:
self._incompatible = incompatible
else:
Errors._mod_load_error(self.directory, "Mod metadata contains invalid incompatibilities")
return false
return true

func _is_valid():
# Check that the incompatible, load before, and load after lists don't have anything common or contain the mod's own ID
var duplicates = {}
var valid_load_order = ([self.id] + self.before + self.after + self.incompatible).filter(func(id):
if id in duplicates:
return true
duplicates[id] = true
return false).is_empty()
# Check that the dependency and incompatible lists don't have anything in common
var valid_dependencies = self.dependencies.filter(func(id): return id in incompatible).is_empty()
if valid_load_order and valid_dependencies:
return true
Errors._mod_load_error(self.directory, "Mod metadata contains invalid load order or invalid dependencies")
return false

func _to_string():
return "{ directory: %s, id: %s, name: %s, author: %s, dependencies: %s, before: %s, after: %s, incompatible: %s }" % [self.directory, self.id, self.name, self.author, self.dependencies, self.before, self.after, self.incompatible]
85 changes: 85 additions & 0 deletions GDScript/modding/mod_loader.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
## Provides methods and properties for loading [Mod]s at runtime, obtaining all loaded [Mod]s, and finding a loaded [Mod] by its ID.
extends Node

var _loaded_mods = {}

## All the [Mod]s that have been loaded at runtime.
var loaded_mods:
get:
return self._loaded_mods

## Loads a [Mod] from [code]mod_directory_path[/code] and runs all [code]_init()[/code] functions in its scripts if [code]execute_scripts[/code] is true.
func load_mod(mod_directory_path, execute_scripts = true):
var metadata = Mod.Metadata._load(mod_directory_path)
if not metadata:
return null
var mod = Mod.new(metadata)
self._loaded_mods[mod.meta.id] = mod
if execute_scripts:
self._startup_mod(mod)
return mod

## Loads [Mod]s from [code]mod_directory_paths[/code] and runs all [code]_init()[/code] functions in their scripts if [code]execute_scripts[/code] is true.
func load_mods(mod_directory_paths, execute_scripts = true):
var mods = []
for metadata in self._sort_mod_metadata(self._filter_mod_metadata(self._load_mod_metadata(mod_directory_paths))):
var mod = Mod.new(metadata)
mods.append(mod)
self._loaded_mods[metadata.id] = mod
if execute_scripts:
for mod in mods:
self._startup_mod(mod)
return mods

func _startup_mod(mod):
for script in mod.scripts:
script.reload()
script.new()
if not (script is RefCounted):
script.free()

func _load_mod_metadata(mod_directory_paths):
var loaded_metadata = {}
for metadata in mod_directory_paths.map(func(mod_directory_path): return Mod.Metadata._load(mod_directory_path)).filter(func(metadata): return metadata != null):
# Fail if the metadata is incompatible with any of the loaded metadata (and vice-versa), or if the ID already exists
var incompatible_metadata = metadata.incompatible.map(func(id): return loaded_metadata[id]).filter(func(loaded): return loaded != null) + loaded_metadata.values().filter(func(loaded): return metadata in loaded.incompatible)
if not incompatible_metadata.is_empty():
Errors._mod_load_error(metadata.directory, "Mod is incompatible with other loaded mods")
continue
elif metadata.id in loaded_metadata:
Errors._mod_load_error(metadata.directory, "Mod has duplicate ID")
continue
loaded_metadata[metadata.id] = metadata
return loaded_metadata

func _filter_mod_metadata(loaded_metadata):
# If the dependencies of any metadata have not been loaded, remove that metadata and try again
var invalid_metadata = loaded_metadata.values().filter(func(metadata): return metadata.dependencies.any(func(dependency): return not dependency in loaded_metadata))
for metadata in invalid_metadata:
Errors._mod_load_error(metadata.directory, "Not all dependencies are loaded")
loaded_metadata.erase(metadata.id)
return self._filter_mod_metadata(loaded_metadata)
return loaded_metadata

func _sort_mod_metadata(filtered_metadata):
if filtered_metadata.is_empty():
return []
# Create a graph of each metadata ID and the IDs of those that need to be loaded after it
var dependency_graph = {}
for metadata in filtered_metadata.values():
if not metadata.id in dependency_graph:
dependency_graph[metadata.id] = []
for after in metadata.after:
dependency_graph[metadata.id].append(after)
for before in metadata.before:
if not before in dependency_graph:
dependency_graph[before] = []
dependency_graph[before].append(metadata.id)
# Topologically sort the dependency graph, removing cyclic dependencies if any
var sorted_metadata = ArrayExtensions.topological_sort(dependency_graph.keys(), func(id): return dependency_graph.get(id, []), func(cyclic):
Errors._mod_load_error(filtered_metadata[cyclic].directory, "Mod has cyclic dependencies with other mods")
filtered_metadata.erase(cyclic))
# If there is no valid topological sorting (cyclic dependencies detected), remove the cyclic metadata and try again
if sorted_metadata.is_empty():
return self._sort_mod_metadata(self._filter_mod_metadata(filtered_metadata))
return sorted_metadata.map(func(id): return filtered_metadata.get(id)).filter(func(metadata): return metadata != null)
4 changes: 4 additions & 0 deletions GDScript/utility/errors.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class_name Errors

static func _mod_load_error(directory_path, message):
push_error("Error loading mod at %s: %s" % [directory_path, message])
26 changes: 26 additions & 0 deletions GDScript/utility/extensions/array_extensions.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class_name ArrayExtensions

## Topologically sorts [code]array[/code] using [code]dependencies[/code] for each element's dependencies, and invoking [code]cyclic[/code] if a cyclic dependency is found.
static func topological_sort(array, dependencies, cyclic):
var sorted = []
var states = {}
var all_valid = array.all(func(element): return ArrayExtensions._visit_dependencies(element, dependencies, cyclic, sorted, states))
return sorted if all_valid else []

static func _visit_dependencies(element, dependencies, cyclic, sorted, states):
if not element in states:
states[element] = false
match states[element]:
true:
return true
false:
states[element] = null
var dependencies_valid = dependencies.call(element).all(func(dependency): return ArrayExtensions._visit_dependencies(dependency, dependencies, cyclic, sorted, states))
if not dependencies_valid:
return false
states[element] = true
sorted.append(element)
return true
null:
cyclic.call(element)
return false
Loading