Skip to content

Commit

Permalink
Merge branch 'what-im-currently-using' into 4.x
Browse files Browse the repository at this point in the history
  • Loading branch information
Alexejhero authored Nov 19, 2024
2 parents 2596a70 + 70dcaba commit bbe46b3
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 61 deletions.
48 changes: 48 additions & 0 deletions addons/mod_loader/api/hook_chain.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
class_name ModLoaderHookChain
extends RefCounted
## Small class to keep the state of hook execution chains and move 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

var _callbacks := []
var _callback_index := -1


const LOG_NAME := "ModLoaderHookChain"


func _init(reference_object: Object, callbacks: Array) -> void:
self.reference_object = reference_object
_callbacks = callbacks
_callback_index = callbacks.size()


## Will execute the next mod hook callable or vanilla method and return the result.[br]
## 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]
##
## [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:
_callback_index -= 1

if not _callback_index >= 0:
ModLoaderLog.fatal(
"The hook chain index should never be negative. " +
"A mod hook has called execute_next twice or ModLoaderHookChain was modified in an unsupported way.",
LOG_NAME
)
return

var callback = _callbacks[_callback_index]

# Vanilla call is always at index 0 and needs to be called without the hook chain being passed
if _callback_index == 0:
return callback.callv(args)

return callback.callv([self] + args)
180 changes: 161 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,146 @@ 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 all methods from a file as hooks. [br]
## The file needs to extend [Object] [br]
## The methods in the file need to have the exact same name as the vanilla method
## they intend to hook, all mismatches will be ignored. [br]
## See: [method add_hook]
##
## [codeblock]
## ModLoaderMod.install_script_hooks(
## "res://tools/utilities.gd",
## extensions_dir_path.path_join("tools/utilities-hook.gd")
## )
## [/codeblock]
static func install_script_hooks(vanilla_script_path: String, hook_script_path: String) -> void:
var hook_script := load(hook_script_path) as GDScript
var hook_script_instance := hook_script.new()

# Every script that inherits RefCounted will be cleaned up by the engine as
# soon as there are no more references to it. If the reference is gone
# the method can't be called and everything returns null.
# Only Object won't be removed, so we can use it here.
if hook_script_instance is RefCounted:
ModLoaderLog.fatal(
"Scripts holding mod hooks should always extend Object (%s)"
% hook_script_path, LOG_NAME
)

var vanilla_script := load(vanilla_script_path) as GDScript
var vanilla_methods := vanilla_script.get_script_method_list().map(
func(method: Dictionary) -> String:
return method.name
)

var methods := hook_script.get_script_method_list()
for hook in methods:
if hook.name in vanilla_methods:
ModLoaderMod.add_hook(Callable(hook_script_instance, hook.name), vanilla_script_path, hook.name)
continue

ModLoaderLog.debug(
'Skipped adding hook "%s" (not found in vanilla script %s)'
% [hook.name, vanilla_script_path], LOG_NAME
)

if not OS.has_feature("editor"):
continue

vanilla_methods.sort_custom((
func(a_name: String, b_name: String, target_name: String) -> bool:
return a_name.similarity(target_name) > b_name.similarity(target_name)
).bind(hook.name))

var closest_vanilla := vanilla_methods.front()
if closest_vanilla.similarity(hook.name) > 0.8:
ModLoaderLog.debug(
'Did you mean "%s" instead of "%s"?'
% [closest_vanilla, hook.name], LOG_NAME
)


## 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 [ModLoaderHookChain] object as first argument,
## which is used to continue down the hook chain (see: [method ModLoaderHookChain.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]
## func _init() -> void:
## ModLoaderMod.add_hook(change_version, "res://main.gd", "_ready")
## ModLoaderMod.add_hook(time_travel, "res://tools/utilities.gd", "format_date")
## # Multiple hooks can be added to a single method.
## 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(chain: ModLoaderHookChain) -> void:
## # Using a typecast here (with "as") can help with autocomplete and avoiding errors
## var main_node := chain.reference_object as MainGame
## main_node.version = "Modloader Hooked!"
## # _ready, which we are hooking, does not have any arguments
## chain.execute_next()
##
##
## # Parameters can be manipulated easily by changing what is passed into .execute_next()
## # The vanilla method (Utilities.format_date) takes 3 arguments, our hook method takes
## # the ModLoaderHookChain followed by the same 3
## func time_travel(chain: ModLoaderHookChain, 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
## var val = chain.execute_next([day, month, year])
## return val
##
##
## # The return value can be manipulated by calling the next hook (or vanilla) first
## # then changing it and returning the new value.
## func add_season(chain: ModLoaderHookChain, day: int, month: int, year: int) -> String:
## var output = chain.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 +197,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 +212,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 +226,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 +240,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 +255,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 +269,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 +300,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 +322,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
33 changes: 19 additions & 14 deletions addons/mod_loader/internal/hooks.gd
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,34 @@ 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 method: "%s" in script: "%s"'
% [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

for mod_func in hooks:
mod_func.callv([self_object] + args)
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 call down until the vanilla method is reached
var callbacks = [vanilla_method]
callbacks.append_array(hooks)
var chain := ModLoaderHookChain.new(vanilla_method.get_object(), callbacks)
return chain.execute_next(args)

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

static func get_hook_hash(path: String, method: String) -> int:
return hash(path + method)
Loading

0 comments on commit bbe46b3

Please sign in to comment.