Skip to content

Commit b88e7d2

Browse files
KANAjetztQubus0pirey0Alexejhero
authored
feat: ✨ param and return manipulation for mod hooks (#469)
* feat: ✨ param and return manipulation for mod hooks * feat: ✨ param and return manipulation for mod hooks * reaname linkage and better user docs * better docs * fix tutorial links * fix call order * refactor old names and better docs * docs fix * feat: ✨ allow installing hooks from a file like extensions * removed recursion from hook call sequence * pr cleanup * slightly more safety * typo protection * fix static type Co-authored-by: Alexejhero <32238504+Alexejhero@users.noreply.github.com> * better static types * docs: ✏️ added comment about untyped `callbacks` array --------- Co-authored-by: Qubus0 <steen.rickmer@gmx.de> Co-authored-by: Luca Martinelli <lucxmangajet@gmail.com> Co-authored-by: Alexejhero <32238504+Alexejhero@users.noreply.github.com>
1 parent 6a3d76e commit b88e7d2

File tree

5 files changed

+249
-60
lines changed

5 files changed

+249
-60
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
class_name ModLoaderHookChain
2+
extends RefCounted
3+
## Small class to keep the state of hook execution chains and move between mod hook calls.[br]
4+
## For examples, see [method ModLoaderMod.add_hook].
5+
6+
7+
## The reference object is usually the [Node] that the vanilla script is attached to. [br]
8+
## If the hooked method is [code]static[/code], it will contain the [GDScript] itself.
9+
var reference_object: Object
10+
11+
var _callbacks: Array[Callable] = []
12+
var _callback_index := -1
13+
14+
15+
const LOG_NAME := "ModLoaderHookChain"
16+
17+
18+
# `callbacks` is kept as untyped Array for simplicity when creating a new chain.
19+
# This approach allows direct use of `[vanilla_method] + hooks` without the need to cast types with Array.assign().
20+
func _init(reference_object: Object, callbacks: Array) -> void:
21+
self.reference_object = reference_object
22+
_callbacks.assign(callbacks)
23+
_callback_index = callbacks.size()
24+
25+
26+
## Will execute the next mod hook callable or vanilla method and return the result.[br]
27+
## Make sure to call this method [i][color=orange]once[/color][/i] somewhere in the [param mod_callable] you pass to [method ModLoaderMod.add_hook]. [br]
28+
##
29+
## [br][b]Parameters:[/b][br]
30+
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
31+
##
32+
## [br][b]Returns:[/b] [Variant][br][br]
33+
func execute_next(args := []) -> Variant:
34+
_callback_index -= 1
35+
36+
if _callback_index < 0:
37+
ModLoaderLog.fatal(
38+
"The hook chain index should never be negative. " +
39+
"A mod hook has called execute_next twice or ModLoaderHookChain was modified in an unsupported way.",
40+
LOG_NAME
41+
)
42+
return
43+
44+
var callback := _callbacks[_callback_index]
45+
46+
# Vanilla call is always at index 0 and needs to be called without the hook chain being passed
47+
if _callback_index == 0:
48+
return callback.callv(args)
49+
50+
return callback.callv([self] + args)

addons/mod_loader/api/mod.gd

Lines changed: 161 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ extends Object
33
##
44
## This Class provides helper functions to build mods.
55
##
6-
## @tutorial(Script Extensions): https://github.com/GodotModding/godot-mod-loader/wiki/Script-Extensions
7-
## @tutorial(Mod Structure): https://github.com/GodotModding/godot-mod-loader/wiki/Mod-Structure
8-
## @tutorial(Mod Files): https://github.com/GodotModding/godot-mod-loader/wiki/Mod-Files
6+
## @tutorial(Script Extensions): https://wiki.godotmodding.com/#/guides/modding/script_extensions
7+
## @tutorial(Script Hooks): https://wiki.godotmodding.com/#/guides/modding/script_hooks
8+
## @tutorial(Mod Structure): https://wiki.godotmodding.com/#/guides/modding/mod_structure
9+
## @tutorial(Mod Files): https://wiki.godotmodding.com/#/guides/modding/mod_files
910

1011

1112
const LOG_NAME := "ModLoader:Mod"
1213

1314

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

4349

44-
## Adds a mod hook
45-
# TODO: detailed doc
46-
static func add_hook(mod_callable: Callable, script_path: String, method_name: String, is_before := false) -> void:
47-
_ModLoaderHooks.add_hook(mod_callable, script_path, method_name, is_before)
50+
## Adds all methods from a file as hooks. [br]
51+
## The file needs to extend [Object] [br]
52+
## The methods in the file need to have the exact same name as the vanilla method
53+
## they intend to hook, all mismatches will be ignored. [br]
54+
## See: [method add_hook]
55+
##
56+
## [codeblock]
57+
## ModLoaderMod.install_script_hooks(
58+
## "res://tools/utilities.gd",
59+
## extensions_dir_path.path_join("tools/utilities-hook.gd")
60+
## )
61+
## [/codeblock]
62+
static func install_script_hooks(vanilla_script_path: String, hook_script_path: String) -> void:
63+
var hook_script := load(hook_script_path) as GDScript
64+
var hook_script_instance := hook_script.new()
65+
66+
# Every script that inherits RefCounted will be cleaned up by the engine as
67+
# soon as there are no more references to it. If the reference is gone
68+
# the method can't be called and everything returns null.
69+
# Only Object won't be removed, so we can use it here.
70+
if hook_script_instance is RefCounted:
71+
ModLoaderLog.fatal(
72+
"Scripts holding mod hooks should always extend Object (%s)"
73+
% hook_script_path, LOG_NAME
74+
)
75+
76+
var vanilla_script := load(vanilla_script_path) as GDScript
77+
var vanilla_methods := vanilla_script.get_script_method_list().map(
78+
func(method: Dictionary) -> String:
79+
return method.name
80+
)
81+
82+
var methods := hook_script.get_script_method_list()
83+
for hook in methods:
84+
if hook.name in vanilla_methods:
85+
ModLoaderMod.add_hook(Callable(hook_script_instance, hook.name), vanilla_script_path, hook.name)
86+
continue
87+
88+
ModLoaderLog.debug(
89+
'Skipped adding hook "%s" (not found in vanilla script %s)'
90+
% [hook.name, vanilla_script_path], LOG_NAME
91+
)
92+
93+
if not OS.has_feature("editor"):
94+
continue
95+
96+
vanilla_methods.sort_custom((
97+
func(a_name: String, b_name: String, target_name: String) -> bool:
98+
return a_name.similarity(target_name) > b_name.similarity(target_name)
99+
).bind(hook.name))
100+
101+
var closest_vanilla: String = vanilla_methods.front()
102+
if closest_vanilla.similarity(hook.name) > 0.8:
103+
ModLoaderLog.debug(
104+
'Did you mean "%s" instead of "%s"?'
105+
% [closest_vanilla, hook.name], LOG_NAME
106+
)
107+
108+
109+
## Adds a hook, a custom mod function, to a vanilla method.[br]
110+
## Opposed to script extensions, hooks can be applied to scripts that use
111+
## [code]class_name[/code] without issues.[br]
112+
## If possible, prefer [method install_script_extension].[br]
113+
##
114+
## [br][b]Parameters:[/b][br]
115+
## - [param mod_callable] ([Callable]): The function that will executed when
116+
## the vanilla method is executed. When writing a mod callable, make sure
117+
## that it [i]always[/i] receives a [ModLoaderHookChain] object as first argument,
118+
## which is used to continue down the hook chain (see: [method ModLoaderHookChain.execute_next])
119+
## and allows manipulating parameters before and return values after the
120+
## vanilla method is called. [br]
121+
## - [param script_path] ([String]): Path to the vanilla script that holds the method.[br]
122+
## - [param method_name] ([String]): The method the hook will be applied to.[br]
123+
##
124+
## [br][b]Returns:[/b] [code]void[/code][br][br]
125+
##
126+
## [b]Examples:[/b]
127+
##
128+
## [br]
129+
## Given the following vanilla script [code]main.gd[/code]
130+
## [codeblock]
131+
## class_name MainGame
132+
## extends Node2D
133+
##
134+
## var version := "vanilla 1.0.0"
135+
##
136+
##
137+
## func _ready():
138+
## $CanvasLayer/Control/Label.text = "Version: %s" % version
139+
## print(Utilities.format_date(15, 11, 2024))
140+
## [/codeblock]
141+
##
142+
## It can be hooked in [code]mod_main.gd[/code] like this
143+
## [codeblock]
144+
## func _init() -> void:
145+
## ModLoaderMod.add_hook(change_version, "res://main.gd", "_ready")
146+
## ModLoaderMod.add_hook(time_travel, "res://tools/utilities.gd", "format_date")
147+
## # Multiple hooks can be added to a single method.
148+
## ModLoaderMod.add_hook(add_season, "res://tools/utilities.gd", "format_date")
149+
##
150+
##
151+
## # The script we are hooking is attached to a node, which we can get from reference_object
152+
## # then we can change any variables it has
153+
## func change_version(chain: ModLoaderHookChain) -> void:
154+
## # Using a typecast here (with "as") can help with autocomplete and avoiding errors
155+
## var main_node := chain.reference_object as MainGame
156+
## main_node.version = "Modloader Hooked!"
157+
## # _ready, which we are hooking, does not have any arguments
158+
## chain.execute_next()
159+
##
160+
##
161+
## # Parameters can be manipulated easily by changing what is passed into .execute_next()
162+
## # The vanilla method (Utilities.format_date) takes 3 arguments, our hook method takes
163+
## # the ModLoaderHookChain followed by the same 3
164+
## func time_travel(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
165+
## print("time travel!")
166+
## year -= 100
167+
## # Just the vanilla arguments are passed along in the same order, wrapped into an Array
168+
## var val = chain.execute_next([day, month, year])
169+
## return val
170+
##
171+
##
172+
## # The return value can be manipulated by calling the next hook (or vanilla) first
173+
## # then changing it and returning the new value.
174+
## func add_season(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
175+
## var output = chain.execute_next([day, month, year])
176+
## match month:
177+
## 12, 1, 2:
178+
## output += ", Winter"
179+
## 3, 4, 5:
180+
## output += ", Spring"
181+
## 6, 7, 8:
182+
## output += ", Summer"
183+
## 9, 10, 11:
184+
## output += ", Autumn"
185+
## return output
186+
## [/codeblock]
187+
##
188+
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
189+
_ModLoaderHooks.add_hook(mod_callable, script_path, method_name)
48190

49191

50192
## Registers an array of classes to the global scope since Godot only does that in the editor.[br]
@@ -55,7 +197,7 @@ static func add_hook(mod_callable: Callable, script_path: String, method_name: S
55197
## (but you should only include classes belonging to your mod)[br]
56198
##
57199
## [br][b]Parameters:[/b][br]
58-
## - [code]new_global_classes[/code] (Array): An array of class definitions to be registered.[br]
200+
## - [param new_global_classes] ([Array]): An array of class definitions to be registered.[br]
59201
##
60202
## [br][b]Returns:[/b] [code]void[/code][br]
61203
static func register_global_classes_from_array(new_global_classes: Array) -> void:
@@ -70,7 +212,7 @@ static func register_global_classes_from_array(new_global_classes: Array) -> voi
70212
## such as when importing a CSV file. The translation file should be in the format [code]mytranslation.en.translation[/code].[/i][br]
71213
##
72214
## [br][b]Parameters:[/b][br]
73-
## - [code]resource_path[/code] (String): The path to the translation resource file.[br]
215+
## - [param resource_path] ([String]): The path to the translation resource file.[br]
74216
##
75217
## [br][b]Returns:[/b] [code]void[/code][br]
76218
static func add_translation(resource_path: String) -> void:
@@ -84,7 +226,7 @@ static func add_translation(resource_path: String) -> void:
84226
ModLoaderLog.info("Added Translation from Resource -> %s" % resource_path, LOG_NAME)
85227
else:
86228
ModLoaderLog.fatal("Failed to load translation at path: %s" % [resource_path], LOG_NAME)
87-
229+
88230

89231

90232
## [i]Note: This function requires Godot 4.3 or higher.[/i][br]
@@ -98,7 +240,7 @@ static func add_translation(resource_path: String) -> void:
98240
## This will reload already loaded scenes and apply the script extension.
99241
## [br]
100242
## [br][b]Parameters:[/b][br]
101-
## - [code]scene_path[/code] (String): The path to the scene file to be refreshed.
243+
## - [param scene_path] ([String]): The path to the scene file to be refreshed.
102244
## [br]
103245
## [br][b]Returns:[/b] [code]void[/code][br]
104246
static func refresh_scene(scene_path: String) -> void:
@@ -113,8 +255,8 @@ static func refresh_scene(scene_path: String) -> void:
113255
## The callable receives an instance of the "vanilla_scene" as the first parameter.[br]
114256
##
115257
## [br][b]Parameters:[/b][br]
116-
## - [code]scene_vanilla_path[/code] (String): The path to the vanilla scene file.[br]
117-
## - [code]edit_callable[/code] (Callable): The callable function to modify the scene.[br]
258+
## - [param scene_vanilla_path] ([String]): The path to the vanilla scene file.[br]
259+
## - [param edit_callable] ([Callable]): The callable function to modify the scene.[br]
118260
##
119261
## [br][b]Returns:[/b] [code]void[/code][br]
120262
static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) -> void:
@@ -127,7 +269,7 @@ static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) ->
127269
## Gets the [ModData] from the provided namespace.[br]
128270
##
129271
## [br][b]Parameters:[/b][br]
130-
## - [code]mod_id[/code] (String): The ID of the mod.[br]
272+
## - [param mod_id] ([String]): The ID of the mod.[br]
131273
##
132274
## [br][b]Returns:[/b][br]
133275
## - [ModData]: The [ModData] associated with the provided [code]mod_id[/code], or null if the [code]mod_id[/code] is invalid.[br]
@@ -158,7 +300,7 @@ static func get_unpacked_dir() -> String:
158300
## Returns true if the mod with the given [code]mod_id[/code] was successfully loaded.[br]
159301
##
160302
## [br][b]Parameters:[/b][br]
161-
## - [code]mod_id[/code] (String): The ID of the mod.[br]
303+
## - [param mod_id] ([String]): The ID of the mod.[br]
162304
##
163305
## [br][b]Returns:[/b][br]
164306
## - [bool]: true if the mod is loaded, false otherwise.[br]
@@ -180,9 +322,9 @@ static func is_mod_loaded(mod_id: String) -> bool:
180322
## Returns true if the mod with the given mod_id was successfully loaded and is currently active.
181323
## [br]
182324
## Parameters:
183-
## - mod_id (String): The ID of the mod.
325+
## - [param mod_id] ([String]): The ID of the mod.
184326
## [br]
185327
## Returns:
186-
## - bool: true if the mod is loaded and active, false otherwise.
328+
## - [bool]: true if the mod is loaded and active, false otherwise.
187329
static func is_mod_active(mod_id: String) -> bool:
188330
return is_mod_loaded(mod_id) and ModLoaderStore.mod_data[mod_id].is_active

addons/mod_loader/internal/hooks.gd

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,37 @@ extends Object
77

88
const LOG_NAME := "ModLoader:Hooks"
99

10-
11-
static func add_hook(mod_callable: Callable, script_path: String, method_name: String, is_before := false) -> void:
10+
## Internal ModLoader method. [br]
11+
## To add hooks from a mod use [method ModLoaderMod.add_hook].
12+
static func add_hook(mod_callable: Callable, script_path: String, method_name: String) -> void:
1213
ModLoaderStore.any_mod_hooked = true
13-
var hash = get_hook_hash(script_path,method_name,is_before)
14+
var hash = get_hook_hash(script_path, method_name)
15+
1416
if not ModLoaderStore.modding_hooks.has(hash):
1517
ModLoaderStore.modding_hooks[hash] = []
1618
ModLoaderStore.modding_hooks[hash].push_back(mod_callable)
17-
ModLoaderLog.debug("Added hook script: \"%s\" %s method: \"%s\""
18-
% [script_path, "before" if is_before else "after", method_name ], LOG_NAME
19+
ModLoaderLog.debug('Added hook "%s" to method: "%s" in script: "%s"'
20+
% [mod_callable.get_method(), method_name, script_path], LOG_NAME
1921
)
22+
2023
if not ModLoaderStore.hooked_script_paths.has(script_path):
2124
ModLoaderStore.hooked_script_paths[script_path] = true
2225

2326

24-
static func call_hooks(self_object: Object, args: Array, hook_hash: int) -> void:
25-
var hooks = ModLoaderStore.modding_hooks.get(hook_hash, null)
26-
if not hooks:
27-
return
27+
static func call_hooks(vanilla_method: Callable, args: Array, hook_hash: int) -> Variant:
28+
var hooks: Array = ModLoaderStore.modding_hooks.get(hook_hash, [])
29+
if hooks.is_empty():
30+
return vanilla_method.callv(args)
31+
32+
# Create a hook chain which will call down until the vanilla method is reached
33+
var callbacks = [vanilla_method]
34+
callbacks.append_array(hooks)
35+
var chain := ModLoaderHookChain.new(vanilla_method.get_object(), callbacks)
36+
return chain.execute_next(args)
37+
2838

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

3242

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

0 commit comments

Comments
 (0)