Skip to content

feat: ✨ Mod Hooks #408

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

Merged
merged 28 commits into from
Nov 8, 2024
Merged

Conversation

KANAjetzt
Copy link
Member

@KANAjetzt KANAjetzt commented Aug 22, 2024

Modding Hooks

  • Export Plugin to create mod hooks
  • New dynamic creation of a .zip file including only necessary mod hooks
  • New Modding API to modify game scripts

New Class _ModLoaderModHookPreProcessor

This class is used to process the source code from a script at a given path.

  • Replaces all vanilla function names with something like vanilla_2924080498__ready()
  • Replaces all instances of super() with super.vanilla_method_name()
  • Check if the function is a getter or setter
  • Check if a function returns something and potential return types
  • Get function parameters with and without types
  • Handle function parameter defaults properly
  • Check for inner classes
  • Check if static or not
  • Check if a function is marked as moddable or not moddalbe
  • Create an imposter function with all the information collected
    • This function includes calls to the mod Callables added via ModLoaderMod.add_hook()

How to use:

  • Instance a new _ModLoaderModHookPreProcessor
  • Call process_script(path) to start processing the source code of a script
  • hook_pre_processor.process_begin() can be called to clear the hashmap storing the hook data.

Result

Before processing
extends LevelBase

@onready var game_times: Node3D = %GameTimes

func _ready() -> void:
	super()
	Global.current_arena_index = level_data.level_id

	Global.game_won.connect(_on_game_won)
	Global.player.camera.tween_completed.connect(_on_player_camera_tween_completed)

func _on_portal_hit_detected(destination: LevelData) -> void:
	Global.player.is_input_disabled = true
	Global.blend()
	await get_tree().create_timer(0.1).timeout
	Global.player.teleport(destination.start_transform)
	Global.player.activate_particles()
	await get_tree().create_timer(0.1).timeout
	destination.ref.activate_camera()

func _on_game_won(first: bool) -> void:
	%PortalNewGame.deactivated = false
	if first and not Global.settings.gameplay_ui_show_level_timer:
		Global.settings.gameplay_ui_show_level_timer = true
	if first and not Global.settings.gameplay_ui_show_overall_timer:
		Global.settings.gameplay_ui_show_overall_timer = true

	game_times.get_child(-1).text = "%s | %s" % [game_times.get_child(-1).text, Global.score]
	add_time(Global.time_game)

func add_time(time: int) -> void:
	var new_label := Label3D.new()
	new_label.text = Global.hud.format_stopwatch(time)
	new_label.font_size = 900
	new_label.translate(Vector3(0, 4 * game_times.get_child_count(), -(4 * game_times.get_child_count())))
	game_times.add_child(new_label)

func update_time(time: int) -> void:
	game_times.get_child(-1).text = Global.hud.format_stopwatch(time)

func activate_camera() -> void:
	super()
	Global.time_game = Global.time_engine

func _on_player_camera_tween_completed() -> void:
	if Global.current_arena_index == 0:
		%PortalNewGame.deactivated = true
		Global.time_game_start = Global.time_engine
		level_data.level_time = 0
After processing
extends LevelBase

@onready var game_times: Node3D = %GameTimes

func vanilla_2924080498__ready() -> void:
	super._ready()
	Global.current_arena_index = level_data.level_id

	Global.game_won.connect(_on_game_won)
	Global.player.camera.tween_completed.connect(_on_player_camera_tween_completed)

func vanilla_2924080498__on_portal_hit_detected(destination: LevelData) -> void:
	Global.player.is_input_disabled = true
	Global.blend()
	await get_tree().create_timer(0.1).timeout
	Global.player.teleport(destination.start_transform)
	Global.player.activate_particles()
	await get_tree().create_timer(0.1).timeout
	destination.ref.activate_camera()

func vanilla_2924080498__on_game_won(first: bool) -> void:
	%PortalNewGame.deactivated = false
	if first and not Global.settings.gameplay_ui_show_level_timer:
		Global.settings.gameplay_ui_show_level_timer = true
	if first and not Global.settings.gameplay_ui_show_overall_timer:
		Global.settings.gameplay_ui_show_overall_timer = true

	game_times.get_child(-1).text = "%s | %s" % [game_times.get_child(-1).text, Global.score]
	add_time(Global.time_game)

func vanilla_2924080498_add_time(time: int) -> void:
	var new_label := Label3D.new()
	new_label.text = Global.hud.format_stopwatch(time)
	new_label.font_size = 900
	new_label.translate(Vector3(0, 4 * game_times.get_child_count(), -(4 * game_times.get_child_count())))
	game_times.add_child(new_label)

func vanilla_2924080498_update_time(time: int) -> void:
	game_times.get_child(-1).text = Global.hud.format_stopwatch(time)

func vanilla_2924080498_activate_camera() -> void:
	super.activate_camera()
	Global.time_game = Global.time_engine

func vanilla_2924080498__on_player_camera_tween_completed() -> void:
	if Global.current_arena_index == 0:
		%PortalNewGame.deactivated = true
		Global.time_game_start = Global.time_engine
		level_data.level_time = 0

# ModLoader Hooks - The following code has been automatically added by the Godot Mod Loader export plugin.

func _ready():
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [], 2563180377)
	vanilla_2924080498__ready()
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [], 2679547544)


func _on_portal_hit_detected(destination: LevelData):
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [destination], 1506468663)
	vanilla_2924080498__on_portal_hit_detected(destination)
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [destination], 3818880694)


func _on_game_won(first: bool):
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [first], 2589280301)
	vanilla_2924080498__on_game_won(first)
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [first], 3981843692)


func add_time(time: int):
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [time], 3756891420)
	vanilla_2924080498_add_time(time)
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [time], 3887075323)


func update_time(time: int):
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [time], 2683732374)
	vanilla_2924080498_update_time(time)
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [time], 730942773)


func activate_camera():
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [], 1280247806)
	vanilla_2924080498_activate_camera()
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [], 37660317)


func _on_player_camera_tween_completed():
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [], 3347287027)
	vanilla_2924080498__on_player_camera_tween_completed()
	if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
		ModLoaderMod.call_hooks(self, [], 1011351538)

New Class _ModLoaderModHookPacker

The _ModLoaderModHookPacker is used for the dynamic creation of the “mod hook pack”. The mod hook pack is a zip archive that includes all transformed script that are required for the currently installed mods.

Hook generation is started in ModLoader _ready(), we have to do it here because we run into issues with variables declared with autoload props as defaults. These autoloads are not available if we run it in _init().

# Hooks must be generated after all autoloads are available.
# Variables initialized with an autoload property otherwise causes errors.
if not OS.has_feature("editor") and ModLoaderStore.any_mod_hooked:
# Generate mod hooks
_ModLoaderModHookPacker.start()

Changes to ModLoaderStore

  • Added a new const MOD_HOOK_PACK_NAME := "mod-hooks.zip" - the default name for the mod hook pack.
  • New props
    • any_mod_hooked - to exit early of no mod hooks are used

    • modding_hooks - stores the main hook data

      # Example:
      var modding_hooks := {
          1917482423: [Callable, Callable],
          3108290668: [Callable],
      }
    • hooked_script_paths - Only stores the hooked script_paths. Used to generate the hooks in _ModLoaderModHookPacker.

      # Example:
      var hooked_script_paths := {
       	"res://game/game.gd": null,
      }

New Export Plugin

The Export plugin can be used to do a full conversion of a project on export.
This will process each script and add hooks to it.
Note: This only affects the exported project, the code in the editor will stay as is.

New API Method in ModLoaderMod

  • add_hook(mod_callable: Callable, script_path: String, method_name: String, is_before := false) -> void
    Installs a hook into a vanilla function.

Parameters:

  • mod_callable (Callable): The Callable that is called at the beginning or the end of the vanilla function.
  • script_path (String): The path to the hooked script.
  • method_name (String): The name of the hooked function.
  • is_before (bool): By default false, if true the hook is placed before the function code.

New Mod Loader Options

Added new options to configure the hook system

  • override_path_to_hook_pack
    Can be used to override the default hook pack path, the hook pack is located inside the game's install directory by default.
    To override the path specify a new absolute path.
  • override_hook_pack_name
    Can be used to override the default hook pack name, by default it is ModLoaderStore.MOD_HOOK_PACK_NAME
  • restart_notification_scene_path
    Can be used to specify your own scene that is displayed if a game restart is required.
    For example if new mod hooks where generated.
  • disable_restart
    Can be used to disable the mod loader's restart logic. Use the ModLoader.new_hooks_created signal to implement your own restart logic.

Restart notification

Added a new restart notification.
The new_hooks_created signal is emitted if new hooks are created meaning a restart is pending.
With the default setting this will instance the new restart_notification scene that will show a countdown and automatically restarts the game to apply the new mod hooks.
This behavior can be modified with the options described above.

Miner Changes

_ModLoaderCache

  • Changed the log in get_data(key: String) if the cache doesn't have the desired key, from error to info.

_ModLoaderPath

  • Added get_path_to_hook_pack() to get the path to the created hook pack with all possible overrides applied.

TODO

Description Todos

  • Add section about new options
  • Add section about ModLoaderMod.add_hook()
  • Add section about restart notification
  • Add section about export plugin
  • Add section about miner changes
    • In path.gd - get_path_to_hook_pack()
    • Changed logging in cache.gd
  • Add an example project
    • Add an example mod

Documentation Todos

  • Add docs for ModLoaderMod.add_hook()
  • New Options
    • override_path_to_hook_pack
    • override_hook_pack_name
    • restart_notification_scene_path
    • disable_restart
  • Add description for game devs
    • Dynamic hook generation
    • Export plugin
    • General overview on how the system works

@KANAjetzt KANAjetzt added the 4.x label Aug 22, 2024
@KANAjetzt KANAjetzt added this to the 4.x - 7.0.0 milestone Aug 22, 2024
@KANAjetzt KANAjetzt self-assigned this Aug 22, 2024
@pirey0 pirey0 force-pushed the feat_export_plugin branch from 5abbd80 to bad077b Compare September 4, 2024 10:55
@KANAjetzt KANAjetzt changed the title feat: 🚧 first draft of the export plugin feat: 🚧 Mod Hooks Oct 2, 2024
@Qubus0 Qubus0 mentioned this pull request Oct 16, 2024
24 tasks
@Qubus0 Qubus0 mentioned this pull request Nov 2, 2024
@KANAjetzt KANAjetzt marked this pull request as ready for review November 5, 2024 11:20
KANAjetzt and others added 20 commits November 5, 2024 12:20
The plan is to automate the creation of hooks for each function in all GDScripts.
Vanilla functions are renamed, and at the bottom of the script, mod loader hooks are generated. These hooks replace the vanilla functions and handle calls to the `callable_stack`.
Currently, I'm stuck on this approach because the method list's return property does not contain any information if there is no return type defined for the method.
That seems to work for filtering out methods that have a return.
Without removing the `class_name` before initializing the script on export, I get this parse error: `hides a global script class`. With these code changes, I get `Value of type [script_path] cannot be assigned to a variable of type [class_name]` if there is a self-assignment somewhere in the script.
Also added a check for static functions and currently working on recognizing inner classes.
check the `type_hint` and add it if it's present.
To check if the function has no space before `func` or `static`, used to ignore functions in inner classes. Later, we might want to figure out a way to include these.
Also removed extra comma in args list
until now all scripts received the "Mod Loader Hooks" header at the bottom
even when no changes where made.
used to ignore setter funcs
Also refactored some of the function/field names to improved the modding experience
added getter method, made both static and moved getter/setter check to is_moddable check
improved performance by exiting early when nothing is hooked
and a first style and type pass
@KANAjetzt KANAjetzt requested review from Qubus0 and a team November 5, 2024 11:21
@KANAjetzt KANAjetzt changed the title feat: 🚧 Mod Hooks feat: ✨ Mod Hooks Nov 6, 2024
@KANAjetzt KANAjetzt requested a review from pirey0 November 6, 2024 19:39

return """
{%STATIC%}func {%METHOD_NAME%}({%METHOD_PARAMS%}){%RETURN_TYPE_STRING%}:
if ModLoaderStore.get("any_mod_hooked") and ModLoaderStore.any_mod_hooked:
Copy link
Contributor

@pirey0 pirey0 Nov 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the ModLoaderStore.get("any_mod_hooked") kills all the performance-benefits of checking ModLoaderStore.any_mod_hooked.
This was originally a way to reduce the modding overhead when running a modless version of a game.
2 topics:

  • can we somehow skip the ModLoaderStore.get("any_mod_hooked") call?
  • can we skip the check altogether when dynamically generating the scripts, since there we only create the methods when hooks actually exist.

(Same below on l.271)

Copy link
Contributor

@pirey0 pirey0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only reviewed the code itself, did not run any tests.
Only change we should really look into is the any_mods_hooked one, remaining feedback is just for code quality.


func _exit_tree() -> void:
# Save the cache stored in ModLoaderStore to the cache file.
_ModLoaderCache.save_to_file()


func are_mods_disabled() -> bool:
return false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't see any reference to this call, looks like some mid-development method that was never used/implemented?


name="A Mod Loader Export"
description="Export plugin to generate the necessary callable stack."
author="KANA"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might make sense to have https://github.com/GodotModding as author instead of just KANA

ModLoaderStore.modding_hooks[hash].push_back(mod_callable)
ModLoaderLog.debug("Added hook script: \"%s\" method: \"%s\" is_before: \"%s\"" % [script_path, method_name, is_before], LOG_NAME)
if not ModLoaderStore.hooked_script_paths.has(script_path):
ModLoaderStore.hooked_script_paths[script_path] = null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: We are using a Dictionary as a Hashset here.
Even though we do not intend to use the dictionary value, it might make sense to store a "truthy" value instead of null so that both has and get can be used in if checks. (I would just use True)
Currently hooked_script_paths.get(script_path, false) would always be falsey, which is not the expected interpretation

var getters_setters := collect_getters_and_setters(source_code, regex_getter_setter)

var moddable_methods := current_script.get_script_method_list().filter(
func is_func_moddable(method: Dictionary):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we inlining this function? For me it just worsens readability.


static func get_closing_paren_index(opening_paren_index: int, text: String) -> int:
# Use a stack to match parentheses
var stack := []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to use a stack here, could just be another accumulator, just doing +1 on "(" and -1 on ")"

@KANAjetzt KANAjetzt merged commit 09c458d into GodotModding:4.x Nov 8, 2024
1 check passed
@KANAjetzt
Copy link
Member Author

The requested changes will be addressed in subsequent PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants