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

feat: ✨ param and return manipulation for mod hooks #436

Merged
merged 21 commits into from
Nov 23, 2024
Merged
Show file tree
Hide file tree
Changes from 11 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
69 changes: 69 additions & 0 deletions addons/mod_loader/api/hook.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
class_name ModLoaderHook
extends RefCounted
## Small class to pass data between mod hook calls.[br]
## For examples, see [method ModLoaderMod.add_hook].


## The reference object is usually the [Node] that the vanilla script is attached to. [br]
## If the hooked method is [code]static[/code], it will contain the [GDScript] itself.
var reference_object: Object

# The mod hook callable or vanilla method
var _method: Callable
var _next_hook: ModLoaderHook


func _init(method: Callable, next_hook: ModLoaderHook = null) -> void:
_method = method
_next_hook = next_hook
reference_object = _next_hook.reference_object if _next_hook else _method.get_object()


## Will execute the next mod hook callable or vanilla method and return the result.[br]
## Make sure to call this method [i]somewhere[/i] in the [param mod_callable] you pass to [method ModLoaderMod.add_hook]. [br]
##
## [br][b]Parameters:[/b][br]
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
##
## [br][b]Returns:[/b] [Variant][br][br]
func execute_next(args := []) -> Variant:
if _next_hook:
return _next_hook._execute(args)

return null


# _execute just brings the logic to the current hook
# instead of having it all "external" in the previous hook's execute_next
func _execute(args := []) -> Variant:
# No next hook means we are at the end of the chain and call the vanilla method directly
if not _next_hook:
return _method.callv(args)

return _method.callv([self] + args)


# This starts the chain of hooks, which goes as follows:
# _execute 1
# calls _method, the stored mod_callable
# if that method is a mod hook, it passes the ModLoaderHook object along
# mod_callable 1
# the mod hook is implemented by modders, here they can change parameters
# it needs to call execute_next, otherwise the chain breaks
# execute_next 1
# that then calls _execute on the next hook
# _execute 2
# calls _method
# if _method contains the vanilla method, it is called directly
# otherwise we go another layer deeper
# _method (vanilla) returns
# _execute 2 returns
# execute_next 1 returns
# mod_callable 1
# at this point the final return value can be modded again
# mod_callable 1 returns
# _execute 1 returns the final value
# and _execute_chain spits it back out to _ModLoaderHooks.call_hooks
# which was called from the processed vanilla method
func _execute_chain(args := []) -> Variant:
return _execute(args)
123 changes: 104 additions & 19 deletions addons/mod_loader/api/mod.gd
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ extends Object
##
## This Class provides helper functions to build mods.
##
## @tutorial(Script Extensions): https://github.com/GodotModding/godot-mod-loader/wiki/Script-Extensions
## @tutorial(Mod Structure): https://github.com/GodotModding/godot-mod-loader/wiki/Mod-Structure
## @tutorial(Mod Files): https://github.com/GodotModding/godot-mod-loader/wiki/Mod-Files
## @tutorial(Script Extensions): https://wiki.godotmodding.com/#/guides/modding/script_extensions
## @tutorial(Script Hooks): https://wiki.godotmodding.com/#/guides/modding/script_hooks
## @tutorial(Mod Structure): https://wiki.godotmodding.com/#/guides/modding/mod_structure
## @tutorial(Mod Files): https://wiki.godotmodding.com/#/guides/modding/mod_files


const LOG_NAME := "ModLoader:Mod"


## Installs a script extension that extends a vanilla script.[br]
## The [code]child_script_path[/code] should point to your mod's extender script.[br]
## This is the preferred way of modifying a vanilla [Script][br]
## Since Godot 4, extensions can cause issues with scripts that use [code]class_name[/code]
## and should be avoided if present.[br]
## See [method add_hook] for those cases.[br]
## [br]
## The [param child_script_path] should point to your mod's extender script.[br]
## Example: [code]"MOD/extensions/singletons/utils.gd"[/code][br]
## Inside the extender script, include [code]extends {target}[/code] where [code]{target}[/code] is the vanilla path.[br]
## Example: [code]extends "res://singletons/utils.gd"[/code].[br]
Expand All @@ -21,7 +27,7 @@ const LOG_NAME := "ModLoader:Mod"
## but it's good practice to do so.[br]
##
## [br][b]Parameters:[/b][br]
## - [code]child_script_path[/code] (String): The path to the mod's extender script.[br]
## - [param child_script_path] ([String]): The path to the mod's extender script.[br]
##
## [br][b]Returns:[/b] [code]void[/code][br]
static func install_script_extension(child_script_path: String) -> void:
Expand All @@ -41,10 +47,89 @@ static func install_script_extension(child_script_path: String) -> void:
_ModLoaderScriptExtension.apply_extension(child_script_path)


## Adds a mod hook
# TODO: detailed doc
static func add_hook(mod_callable: Callable, script_path: String, method_name: String, is_before := false) -> void:
_ModLoaderHooks.add_hook(mod_callable, script_path, method_name, is_before)
## Adds a hook, a custom mod function, to a vanilla method.[br]
## Opposed to script extensions, hooks can be applied to scripts that use
## [code]class_name[/code] without issues.[br]
## If possible, prefer [method install_script_extension].[br]
##
## [br][b]Parameters:[/b][br]
## - [param mod_callable] ([Callable]): The function that will executed when
## the vanilla method is executed. When writing a mod callable, make sure
## that it [i]always[/i] receives a [ModLoaderHook] object as first argument,
## which is used to continue down the hook chain (see: [method ModLoaderHook.execute_next])
## and allows manipulating parameters before and return values after the
## vanilla method is called. [br]
## - [param script_path] ([String]): Path to the vanilla script that holds the method.[br]
## - [param method_name] ([String]): The method the hook will be applied to.[br]
##
## [br][b]Returns:[/b] [code]void[/code][br][br]
##
## [b]Examples:[/b]
##
## [br]
## Given the following vanilla script [code]main.gd[/code]
## [codeblock]
## class_name MainGame
## extends Node2D
##
## var version := "vanilla 1.0.0"
##
##
## func _ready():
## $CanvasLayer/Control/Label.text = "Version: %s" % version
## print(Utilities.format_date(15, 11, 2024))
## [/codeblock]
##
## It can be hooked in [code]mod_main.gd[/code] like this
## [codeblock]
## extends Node
##
##
## func _init() -> void:
## ModLoaderMod.add_hook(change_version, "res://main.gd", "_ready")
## ModLoaderMod.add_hook(time_travel, "res://tools/utilities.gd", "format_date")
## ModLoaderMod.add_hook(add_season, "res://tools/utilities.gd", "format_date")
##
##
## ## The script we are hooking is attached to a node, which we can get from reference_object
## ## then we can change any variables it has
## func change_version(hook: ModLoaderHook) -> void:
## # Using a typecast here (with "as") can help with autocomplete and avoiding errors
## var main_node := hook.reference_object as MainGame
## main_node.version = "Modloader Hooked!"
## # _ready, which we are hooking, does not have any arguments
## hook.execute_next()
##
##
## ## Parameters can be manipulated easily by changing what is passed into .execute_next()
Qubus0 marked this conversation as resolved.
Show resolved Hide resolved
## ## The vanilla method (Utilities.format_date) takes 3 arguments, our hook method takes
## ## the ModLoaderHook followed by the same 3
## func time_travel(hook: ModLoaderHook, day: int, month: int, year: int) -> String:
## print("time travel!")
## year -= 100
## # Just the vanilla arguments are passed along in the same order, wrapped into an Array
## return hook.execute_next([day, month, year])
##
##
## ## The return value can be manipulated by calling the next hook (or vanilla) first
## ## then changing it and returning the new value.
## ## Multiple hooks can be added to a single method.
## func add_season(hook: ModLoaderHook, day: int, month: int, year: int) -> String:
## var output = hook.execute_next([day, month, year])
## match month:
## 12, 1, 2:
## output += ", Winter"
## 3, 4, 5:
## output += ", Spring"
## 6, 7, 8:
## output += ", Summer"
## 9, 10, 11:
## output += ", Autumn"
## return output
## [/codeblock]
##
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
_ModLoaderHooks.add_hook(mod_callable, script_path, method_name)


## Registers an array of classes to the global scope since Godot only does that in the editor.[br]
Expand All @@ -55,7 +140,7 @@ static func add_hook(mod_callable: Callable, script_path: String, method_name: S
## (but you should only include classes belonging to your mod)[br]
##
## [br][b]Parameters:[/b][br]
## - [code]new_global_classes[/code] (Array): An array of class definitions to be registered.[br]
## - [param new_global_classes] ([Array]): An array of class definitions to be registered.[br]
##
## [br][b]Returns:[/b] [code]void[/code][br]
static func register_global_classes_from_array(new_global_classes: Array) -> void:
Expand All @@ -70,7 +155,7 @@ static func register_global_classes_from_array(new_global_classes: Array) -> voi
## such as when importing a CSV file. The translation file should be in the format [code]mytranslation.en.translation[/code].[/i][br]
##
## [br][b]Parameters:[/b][br]
## - [code]resource_path[/code] (String): The path to the translation resource file.[br]
## - [param resource_path] ([String]): The path to the translation resource file.[br]
##
## [br][b]Returns:[/b] [code]void[/code][br]
static func add_translation(resource_path: String) -> void:
Expand All @@ -84,7 +169,7 @@ static func add_translation(resource_path: String) -> void:
ModLoaderLog.info("Added Translation from Resource -> %s" % resource_path, LOG_NAME)
else:
ModLoaderLog.fatal("Failed to load translation at path: %s" % [resource_path], LOG_NAME)



## [i]Note: This function requires Godot 4.3 or higher.[/i][br]
Expand All @@ -98,7 +183,7 @@ static func add_translation(resource_path: String) -> void:
## This will reload already loaded scenes and apply the script extension.
## [br]
## [br][b]Parameters:[/b][br]
## - [code]scene_path[/code] (String): The path to the scene file to be refreshed.
## - [param scene_path] ([String]): The path to the scene file to be refreshed.
## [br]
## [br][b]Returns:[/b] [code]void[/code][br]
static func refresh_scene(scene_path: String) -> void:
Expand All @@ -113,8 +198,8 @@ static func refresh_scene(scene_path: String) -> void:
## The callable receives an instance of the "vanilla_scene" as the first parameter.[br]
##
## [br][b]Parameters:[/b][br]
## - [code]scene_vanilla_path[/code] (String): The path to the vanilla scene file.[br]
## - [code]edit_callable[/code] (Callable): The callable function to modify the scene.[br]
## - [param scene_vanilla_path] ([String]): The path to the vanilla scene file.[br]
## - [param edit_callable] ([Callable]): The callable function to modify the scene.[br]
##
## [br][b]Returns:[/b] [code]void[/code][br]
static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) -> void:
Expand All @@ -127,7 +212,7 @@ static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) ->
## Gets the [ModData] from the provided namespace.[br]
##
## [br][b]Parameters:[/b][br]
## - [code]mod_id[/code] (String): The ID of the mod.[br]
## - [param mod_id] ([String]): The ID of the mod.[br]
##
## [br][b]Returns:[/b][br]
## - [ModData]: The [ModData] associated with the provided [code]mod_id[/code], or null if the [code]mod_id[/code] is invalid.[br]
Expand Down Expand Up @@ -158,7 +243,7 @@ static func get_unpacked_dir() -> String:
## Returns true if the mod with the given [code]mod_id[/code] was successfully loaded.[br]
##
## [br][b]Parameters:[/b][br]
## - [code]mod_id[/code] (String): The ID of the mod.[br]
## - [param mod_id] ([String]): The ID of the mod.[br]
##
## [br][b]Returns:[/b][br]
## - [bool]: true if the mod is loaded, false otherwise.[br]
Expand All @@ -180,9 +265,9 @@ static func is_mod_loaded(mod_id: String) -> bool:
## Returns true if the mod with the given mod_id was successfully loaded and is currently active.
## [br]
## Parameters:
## - mod_id (String): The ID of the mod.
## - [param mod_id] ([String]): The ID of the mod.
## [br]
## Returns:
## - bool: true if the mod is loaded and active, false otherwise.
## - [bool]: true if the mod is loaded and active, false otherwise.
static func is_mod_active(mod_id: String) -> bool:
return is_mod_loaded(mod_id) and ModLoaderStore.mod_data[mod_id].is_active
36 changes: 23 additions & 13 deletions addons/mod_loader/internal/hooks.gd
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,39 @@ extends Object

const LOG_NAME := "ModLoader:Hooks"


static func add_hook(mod_callable: Callable, script_path: String, method_name: String, is_before := false) -> void:
## Internal ModLoader method. [br]
## To add hooks from a mod use [method ModLoaderMod.add_hook].
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
ModLoaderStore.any_mod_hooked = true
var hash = get_hook_hash(script_path,method_name,is_before)
var hash = get_hook_hash(script_path, method_name)

if not ModLoaderStore.modding_hooks.has(hash):
ModLoaderStore.modding_hooks[hash] = []
ModLoaderStore.modding_hooks[hash].push_back(mod_callable)
ModLoaderLog.debug("Added hook script: \"%s\" %s method: \"%s\""
% [script_path, "before" if is_before else "after", method_name ], LOG_NAME
ModLoaderLog.debug('Added hook "%s" to to method: "%s" in script: "%s"'
Qubus0 marked this conversation as resolved.
Show resolved Hide resolved
% [mod_callable.get_method(), method_name, script_path], LOG_NAME
)

if not ModLoaderStore.hooked_script_paths.has(script_path):
ModLoaderStore.hooked_script_paths[script_path] = true


static func call_hooks(self_object: Object, args: Array, hook_hash: int) -> void:
var hooks = ModLoaderStore.modding_hooks.get(hook_hash, null)
if not hooks:
return
static func call_hooks(vanilla_method: Callable, args: Array, hook_hash: int) -> Variant:
var hooks: Array = ModLoaderStore.modding_hooks.get(hook_hash, [])
if hooks.is_empty():
return vanilla_method.callv(args)

# Create a hook chain which will recursively call down until the vanilla method is reached
Qubus0 marked this conversation as resolved.
Show resolved Hide resolved
hooks.reverse()
Qubus0 marked this conversation as resolved.
Show resolved Hide resolved
var chain_hook := ModLoaderHook.new(vanilla_method)
Qubus0 marked this conversation as resolved.
Show resolved Hide resolved
for mod_func in hooks:
chain_hook = ModLoaderHook.new(mod_func, chain_hook)

return chain_hook._execute_chain(args)


for mod_func: Callable in hooks:
mod_func.callv([self_object] + args)
static func get_hook_hash(path: String, method: String) -> int:
return hash(path + method)


static func get_hook_hash(path:String, method:String, is_before:bool) -> int:
return hash(path + method + ("before" if is_before else "after"))

Loading