Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Rewrite applying extensions #365

Draft
wants to merge 1 commit into
base: 4.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 4 additions & 8 deletions addons/mod_loader/api/mod.gd
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,10 @@ static func install_script_extension(child_script_path: String) -> void:
ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()] = []
ModLoaderStore.saved_extension_paths[mod_data.manifest.get_mod_id()].append(child_script_path)

# If this is called during initialization, add it with the other
# extensions to be installed taking inheritance chain into account
if ModLoaderStore.is_initializing:
ModLoaderStore.script_extensions.push_back(child_script_path)

# If not, apply the extension directly
else:
_ModLoaderScriptExtension.apply_extension(child_script_path)
if not ModLoaderStore.is_initializing:
ModLoaderLog.error("Cannot install extension after init", LOG_NAME)
return
ModLoaderStore.script_extensions.push_back(child_script_path)


# Register an array of classes to the global scope since Godot only does that in the editor.
Expand Down
70 changes: 0 additions & 70 deletions addons/mod_loader/api/mod_manager.gd
Original file line number Diff line number Diff line change
Expand Up @@ -5,73 +5,3 @@ extends RefCounted


const LOG_NAME := "ModLoader:Manager"


# Uninstall a script extension.
#
# Parameters:
# - extension_script_path (String): The path to the extension script to be uninstalled.
#
# Returns: void
static func uninstall_script_extension(extension_script_path: String) -> void:
# Currently this is the only thing we do, but it is better to expose
# this function like this for further changes
_ModLoaderScriptExtension.remove_specific_extension_from_script(extension_script_path)


# Reload all mods.
#
# *Note: This function should be called only when actually necessary
# as it can break the game and require a restart for mods
# that do not fully use the systems put in place by the mod loader,
# so anything that just uses add_node, move_node ecc...
# To not have your mod break on reload please use provided functions
# like ModLoader::save_scene, ModLoader::append_node_in_scene and
# all the functions that will be added in the next versions
# Used to reload already present mods and load new ones*
#
# Returns: void
static func reload_mods() -> void:

# Currently this is the only thing we do, but it is better to expose
# this function like this for further changes
ModLoader._reload_mods()


# Disable all mods.
#
# *Note: This function should be called only when actually necessary
# as it can break the game and require a restart for mods
# that do not fully use the systems put in place by the mod loader,
# so anything that just uses add_node, move_node ecc...
# To not have your mod break on disable please use provided functions
# and implement a _disable function in your mod_main.gd that will
# handle removing all the changes that were not done through the Mod Loader*
#
# Returns: void
static func disable_mods() -> void:

# Currently this is the only thing we do, but it is better to expose
# this function like this for further changes
ModLoader._disable_mods()


# Disable a mod.
#
# *Note: This function should be called only when actually necessary
# as it can break the game and require a restart for mods
# that do not fully use the systems put in place by the mod loader,
# so anything that just uses add_node, move_node ecc...
# To not have your mod break on disable please use provided functions
# and implement a _disable function in your mod_main.gd that will
# handle removing all the changes that were not done through the Mod Loader*
#
# Parameters:
# - mod_data (ModData): The ModData object representing the mod to be disabled.
#
# Returns: void
static func disable_mod(mod_data: ModData) -> void:

# Currently this is the only thing we do, but it is better to expose
# this function like this for further changes
ModLoader._disable_mod(mod_data)
255 changes: 71 additions & 184 deletions addons/mod_loader/internal/script_extension.gd
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,78 @@ const LOG_NAME := "ModLoader:ScriptExtension"
# Sort script extensions by inheritance and apply them in order
static func handle_script_extensions() -> void:
var extension_paths := []
for extension_path in ModLoaderStore.script_extensions:
if FileAccess.file_exists(extension_path):
extension_paths.push_back(extension_path)
for path in ModLoaderStore.script_extensions:
if FileAccess.file_exists(path):
extension_paths.push_back(path)
else:
ModLoaderLog.error(
"The child script path '%s' does not exist" % [extension_path], LOG_NAME
)

# Sort by inheritance
extension_paths.sort_custom(Callable(InheritanceSorting, "_check_inheritances"))

# Load and install all extensions
for extension in extension_paths:
var script: Script = apply_extension(extension)
_reload_vanilla_child_classes_for(script)

ModLoaderLog.error("Extension script path '%s' does not exist" % [path], LOG_NAME)

# sort by inheritance
extension_paths.sort_custom(InheritanceSorting.new()._check_inheritances)

# used to replace the extends clause in the script
var extendsRegex := RegEx.new()
extendsRegex.compile("(?m)^extends \\\"(.*)\\\"")

var chains = {}

# prepare extensions chains: apply extends overrides, copy orig, ...
for path in extension_paths:
var script: Script = ResourceLoader.load(path)
var extending_script: Script = script.get_base_script()
if not extending_script:
ModLoaderLog.error("Extension script '%s' does not inherit from any other script" % [path], LOG_NAME)
continue
var extending_path: String = extending_script.resource_path
if extending_path.is_empty():
ModLoaderLog.error("extending_path in '%s' is empty ??" % [path], LOG_NAME)
continue

if not chains.has(extending_path):
var orig_path := "res://mod_loader_temp/" + extending_path.trim_prefix("res://")
# copy original script to the new path
_write_file(orig_path, extending_script.source_code)
chains[extending_path] = [ResourceLoader.load(orig_path)]

var prev_path: String = chains[extending_path].front().resource_path
ModLoaderLog.info("Patching extends: %s -> %s" % [path, prev_path], LOG_NAME)
var patched_path := "res://mod_loader_temp/" + script.resource_path.trim_prefix("res://")
_write_file(patched_path, extendsRegex.sub(script.source_code, "extends \"" + prev_path + "\""))
chains[extending_path].push_front(ResourceLoader.load(patched_path))

# apply final source code override and reload everything
for extending_path in chains:
var chain: Array = chains[extending_path]

# overwrite real original script's code with the last extension,
# the hierarchy would eventually reach our copied original script
ModLoaderLog.info("Overwriting: %s -> %s" % [chain.front().resource_path, extending_path], LOG_NAME)
var extending_script: Script = ResourceLoader.load(extending_path)
extending_script.source_code = chain.front().source_code

# reload order goes from orig to last applied extension
ModLoaderLog.info("Reloading chain: %s" % [extending_path], LOG_NAME)
for i in range(chain.size() - 1, 0, -1):
var script: Script = chain[i]
ModLoaderLog.info(" Reloading: %s" % [script.resource_path], LOG_NAME)
script.reload()
ModLoaderLog.info(" Reloading: %s" % [extending_path], LOG_NAME)
extending_script.reload()

# clear temp directory
for i in range(chain.size() - 1, -1, -1):
var p: String = chain[i].resource_path.trim_prefix("res://")
while true:
var err := DirAccess.remove_absolute(p)
if err != Error.OK:
break
p = p.get_base_dir()
if not p.begins_with("mod_loader_temp"):
break

static func _write_file(path: String, contents: String) -> void:
DirAccess.make_dir_recursive_absolute(path.get_base_dir().trim_prefix("res://"))
FileAccess.open(path, FileAccess.ModeFlags.WRITE).store_string(contents)

# Sorts script paths by their ancestors. Scripts are organized by their common
# ancestors then sorted such that scripts extending script A will be before
Expand Down Expand Up @@ -70,172 +126,3 @@ class InheritanceSorting:

stack_cache[extension_path] = stack
return stack


static func apply_extension(extension_path: String) -> Script:
# Check path to file exists
if not FileAccess.file_exists(extension_path):
ModLoaderLog.error("The child script path '%s' does not exist" % [extension_path], LOG_NAME)
return null

var child_script: Script = ResourceLoader.load(extension_path)
# Adding metadata that contains the extension script path
# We cannot get that path in any other way
# Passing the child_script as is would return the base script path
# Passing the .duplicate() would return a '' path
child_script.set_meta("extension_script_path", extension_path)

# Force Godot to compile the script now.
# We need to do this here to ensure that the inheritance chain is
# properly set up, and multiple mods can chain-extend the same
# class multiple times.
# This is also needed to make Godot instantiate the extended class
# when creating singletons.
# The actual instance is thrown away.
child_script.new()

var parent_script: Script = child_script.get_base_script()
var parent_script_path: String = parent_script.resource_path

# We want to save scripts for resetting later
# All the scripts are saved in order already
if not ModLoaderStore.saved_scripts.has(parent_script_path):
ModLoaderStore.saved_scripts[parent_script_path] = []
# The first entry in the saved script array that has the path
# used as a key will be the duplicate of the not modified script
ModLoaderStore.saved_scripts[parent_script_path].append(parent_script.duplicate())

ModLoaderStore.saved_scripts[parent_script_path].append(child_script)

ModLoaderLog.info(
"Installing script extension: %s <- %s" % [parent_script_path, extension_path], LOG_NAME
)
child_script.take_over_path(parent_script_path)

return child_script


# Reload all children classes of the vanilla class we just extended
# Calling reload() the children of an extended class seems to allow them to be extended
# e.g if B is a child class of A, reloading B after apply an extender of A allows extenders of B to properly extend B, taking A's extender(s) into account
static func _reload_vanilla_child_classes_for(script: Script) -> void:
if script == null:
return
var current_child_classes := []
var actual_path: String = script.get_base_script().resource_path
var classes: Array = ProjectSettings.get_global_class_list()

for _class in classes:
if _class.path == actual_path:
current_child_classes.push_back(_class)
break

for _class in current_child_classes:
for child_class in classes:
if child_class.base == _class.get_class():
load(child_class.path).reload()


# Used to remove a specific extension
static func remove_specific_extension_from_script(extension_path: String) -> void:
# Check path to file exists
if not _ModLoaderFile.file_exists(extension_path):
ModLoaderLog.error(
'The extension script path "%s" does not exist' % [extension_path], LOG_NAME
)
return

var extension_script: Script = ResourceLoader.load(extension_path)
var parent_script: Script = extension_script.get_base_script()
var parent_script_path: String = parent_script.resource_path

# Check if the script to reset has been extended
if not ModLoaderStore.saved_scripts.has(parent_script_path):
ModLoaderLog.error(
'The extension parent script path "%s" has not been extended' % [parent_script_path],
LOG_NAME
)
return

# Check if the script to reset has anything actually saved
# If we ever encounter this it means something went very wrong in extending
if not ModLoaderStore.saved_scripts[parent_script_path].size() > 0:
ModLoaderLog.error(
(
'The extension script path "%s" does not have the base script saved, this should never happen, if you encounter this please create an issue in the github repository'
% [parent_script_path]
),
LOG_NAME
)
return

var parent_script_extensions: Array = ModLoaderStore.saved_scripts[parent_script_path].duplicate()
parent_script_extensions.remove_at(0)

# Searching for the extension that we want to remove
var found_script_extension: Script = null
for script_extension in parent_script_extensions:
if script_extension.get_meta("extension_script_path") == extension_path:
found_script_extension = script_extension
break

if found_script_extension == null:
ModLoaderLog.error(
(
'The extension script path "%s" has not been found in the saved extension of the base script'
% [parent_script_path]
),
LOG_NAME
)
return
parent_script_extensions.erase(found_script_extension)

# Preparing the script to have all other extensions reapllied
_remove_all_extensions_from_script(parent_script_path)

# Reapplying all the extensions without the removed one
for script_extension in parent_script_extensions:
apply_extension(script_extension.get_meta("extension_script_path"))


# Used to fully reset the provided script to a state prior of any extension
static func _remove_all_extensions_from_script(parent_script_path: String) -> void:
# Check path to file exists
if not _ModLoaderFile.file_exists(parent_script_path):
ModLoaderLog.error(
'The parent script path "%s" does not exist' % [parent_script_path], LOG_NAME
)
return

# Check if the script to reset has been extended
if not ModLoaderStore.saved_scripts.has(parent_script_path):
ModLoaderLog.error(
'The parent script path "%s" has not been extended' % [parent_script_path], LOG_NAME
)
return

# Check if the script to reset has anything actually saved
# If we ever encounter this it means something went very wrong in extending
if not ModLoaderStore.saved_scripts[parent_script_path].size() > 0:
ModLoaderLog.error(
(
'The parent script path "%s" does not have the base script saved, \nthis should never happen, if you encounter this please create an issue in the github repository'
% [parent_script_path]
),
LOG_NAME
)
return

var parent_script: Script = ModLoaderStore.saved_scripts[parent_script_path][0]
parent_script.take_over_path(parent_script_path)

# Remove the script after it has been reset so we do not do it again
ModLoaderStore.saved_scripts.erase(parent_script_path)


# Used to remove all extensions that are of a specific mod
static func remove_all_extensions_of_mod(mod: ModData) -> void:
var _to_remove_extension_paths: Array = ModLoaderStore.saved_extension_paths[mod.manifest.get_mod_id()]
for extension_path in _to_remove_extension_paths:
remove_specific_extension_from_script(extension_path)
ModLoaderStore.saved_extension_paths.erase(mod.manifest.get_mod_id())
Loading