added the game

This commit is contained in:
JHDev2006
2025-09-13 16:30:32 +01:00
parent 5ef689109b
commit 3773bdaf64
3616 changed files with 263702 additions and 0 deletions

121
addons/mod_loader/LICENSE Normal file
View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View File

@@ -0,0 +1,27 @@
extends EditorExportPlugin
static var hook_pre_processor: _ModLoaderModHookPreProcessor
func _get_name() -> String:
return "Godot Mod Loader Export Plugin"
func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
hook_pre_processor = _ModLoaderModHookPreProcessor.new()
hook_pre_processor.process_begin()
func _export_file(path: String, type: String, features: PackedStringArray) -> void:
if path.begins_with("res://addons") or path.begins_with("res://mods-unpacked"):
return
if type != "GDScript":
return
skip()
add_file(
path,
hook_pre_processor.process_script(path, true).to_utf8_buffer(),
false
)

View File

@@ -0,0 +1 @@
uid://b0csnkkiudklo

View File

@@ -0,0 +1,7 @@
[plugin]
name="A Mod Loader Hooks Exporter"
description="Export plugin to insert static mod hooks into each script."
author="Godot Modding"
version="0.1"
script="plugin.gd"

View File

@@ -0,0 +1,14 @@
@tool
extends EditorPlugin
var _export_plugin: EditorExportPlugin
func _enter_tree():
_export_plugin = preload("res://addons/mod_loader/_export_plugin/export_plugin.gd").new()
add_export_plugin(_export_plugin)
func _exit_tree() -> void:
remove_export_plugin(_export_plugin)

View File

@@ -0,0 +1 @@
uid://l36n5fcc565s

View File

@@ -0,0 +1,410 @@
class_name ModLoaderConfig
extends Object
##
## Class for managing per-mod configurations.
##
## @tutorial(Creating a Mod Config Schema with JSON-Schemas): https://wiki.godotmodding.com/guides/modding/config_json/
const LOG_NAME := "ModLoader:Config"
const DEFAULT_CONFIG_NAME := "default"
## Creates a new configuration for a mod.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param config_name] ([String]): The name of the configuration.[br]
## - [param config_data] ([Dictionary]): The configuration data to be stored.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The created [ModConfig] object if successful, or null otherwise.
static func create_config(mod_id: String, config_name: String, config_data: Dictionary) -> ModConfig:
var default_config: ModConfig = get_default_config(mod_id)
if not default_config:
ModLoaderLog.error(
"Failed to create config \"%s\". No config schema found for \"%s\"."
% [config_name, mod_id], LOG_NAME
)
return null
# Make sure the config name is not empty
if config_name == "":
ModLoaderLog.error(
"Failed to create config \"%s\". The config name cannot be empty."
% config_name, LOG_NAME
)
return null
# Make sure the config name is unique
if ModLoaderStore.mod_data[mod_id].configs.has(config_name):
ModLoaderLog.error(
"Failed to create config \"%s\". A config with the name \"%s\" already exists."
% [config_name, config_name], LOG_NAME
)
return null
# Create the config save path based on the config_name
var config_file_path := _ModLoaderPath.get_path_to_mod_configs_dir(mod_id).path_join("%s.json" % config_name)
# Initialize a new ModConfig object with the provided parameters
var mod_config := ModConfig.new(
mod_id,
config_data,
config_file_path
)
# Check if the mod_config is valid
if not mod_config.is_valid:
return null
# Store the mod_config in the mod's ModData
ModLoaderStore.mod_data[mod_id].configs[config_name] = mod_config
# Save the mod_config to a new config JSON file in the mod's config directory
var is_save_success := mod_config.save_to_file()
if not is_save_success:
return null
ModLoaderLog.debug("Created new config \"%s\" for mod \"%s\"" % [config_name, mod_id], LOG_NAME)
return mod_config
## Updates an existing [ModConfig] object with new data and saves the config file.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object to be updated.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The updated [ModConfig] object if successful, or null otherwise.
static func update_config(config: ModConfig) -> ModConfig:
# Validate the config and check for any validation errors
var error_message := config.validate()
# Check if the config is the "default" config, which cannot be modified
if config.name == DEFAULT_CONFIG_NAME:
ModLoaderLog.error("The \"default\" config cannot be modified. Please create a new config instead.", LOG_NAME)
return null
# Check if the config passed validation
if not config.is_valid:
ModLoaderLog.error("Update for config \"%s\" failed validation with error message \"%s\"" % [config.name, error_message], LOG_NAME)
return null
# Save the updated config to the config file
var is_save_success := config.save_to_file()
if not is_save_success:
ModLoaderLog.error("Failed to save config \"%s\" to \"%s\"." % [config.name, config.save_path], LOG_NAME)
return null
# Return the updated config
return config
## Deletes a [ModConfig] object and performs cleanup operations.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object to be deleted.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if the deletion was successful, False otherwise.
static func delete_config(config: ModConfig) -> bool:
# Check if the config is the "default" config, which cannot be deleted
if config.name == DEFAULT_CONFIG_NAME:
ModLoaderLog.error("Deletion of the default configuration is not allowed.", LOG_NAME)
return false
# Change the current config to the "default" config
set_current_config(get_default_config(config.mod_id))
# Remove the config file from the Mod Config directory
var is_remove_success := config.remove_file()
if not is_remove_success:
return false
# Remove the config from ModData
ModLoaderStore.mod_data[config.mod_id].configs.erase(config.name)
return true
## Sets the current configuration of a mod to the specified configuration.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object to be set as current config.
static func set_current_config(config: ModConfig) -> void:
ModLoaderStore.mod_data[config.mod_id].current_config = config
## Returns the schema for the specified mod id.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - A dictionary representing the schema for the mod's configuration file.
static func get_config_schema(mod_id: String) -> Dictionary:
# Get all config files for the specified mod
var mod_configs := get_configs(mod_id)
# If no config files were found, return an empty dictionary
if mod_configs.is_empty():
return {}
# The schema is the same for all config files, so we just return the schema of the default config file
return mod_configs.default.schema
## Retrieves the schema for a specific property key.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object from which to retrieve the schema.[br]
## - [param prop] ([String]): The property key for which to retrieve the schema.[br]
## [br]
## [b]Returns:[/b][br]
## - [Dictionary]: The schema dictionary for the specified property.
static func get_schema_for_prop(config: ModConfig, prop: String) -> Dictionary:
# Split the property string into an array of property keys
var prop_array := prop.split(".")
# If the property array is empty, return the schema for the root property
if prop_array.is_empty():
return config.schema.properties[prop]
# Traverse the schema dictionary to find the schema for the specified property
var schema_for_prop := _traverse_schema(config.schema.properties, prop_array)
# If the schema for the property is empty, log an error and return an empty dictionary
if schema_for_prop.is_empty():
ModLoaderLog.error("No Schema found for property \"%s\" in config \"%s\" for mod \"%s\"" % [prop, config.name, config.mod_id], LOG_NAME)
return {}
return schema_for_prop
# Recursively traverses the schema dictionary based on the provided [code]prop_key_array[/code]
# and returns the corresponding schema for the target property.[br]
# [br]
# [b]Parameters:[/b][br]
# - [param schema_prop]: The current schema dictionary to traverse.[br]
# - [param prop_key_array]: An array containing the property keys representing the path to the target property.[br]
# [br]
# [b]Returns:[/b][br]
# - [Dictionary]: The schema dictionary corresponding to the target property specified by the [code]prop_key_array[/code].
# If the target property is not found, an empty dictionary is returned.
static func _traverse_schema(schema_prop: Dictionary, prop_key_array: Array) -> Dictionary:
# Return the current schema_prop if the prop_key_array is empty (reached the destination property)
if prop_key_array.is_empty():
return schema_prop
# Get and remove the first prop_key in the array
var prop_key: String = prop_key_array.pop_front()
# Check if the searched property exists
if not schema_prop.has(prop_key):
return {}
schema_prop = schema_prop[prop_key]
# If the schema_prop has a 'type' key, is of type 'object', and there are more property keys remaining
if schema_prop.has("type") and schema_prop.type == "object" and not prop_key_array.is_empty():
# Set the properties of the object as the current 'schema_prop'
schema_prop = schema_prop.properties
schema_prop = _traverse_schema(schema_prop, prop_key_array)
return schema_prop
## Retrieves an Array of mods that have configuration files.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An Array containing the mod data of mods that have configuration files.
static func get_mods_with_config() -> Array:
# Create an empty array to store mods with configuration files
var mods_with_config := []
# Iterate over each mod in ModLoaderStore.mod_data
for mod_id in ModLoaderStore.mod_data:
# Retrieve the mod data for the current mod ID
# *The ModData type cannot be used because ModData is not fully loaded when this code is executed.*
var mod_data = ModLoaderStore.mod_data[mod_id]
# Check if the mod has any configuration files
if not mod_data.configs.is_empty():
mods_with_config.push_back(mod_data)
# Return the array of mods with configuration files
return mods_with_config
## Retrieves the configurations dictionary for a given mod ID.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id]: The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [Dictionary]: A dictionary containing the configurations for the specified mod.
## If the mod ID is invalid or no configurations are found, an empty dictionary is returned.
static func get_configs(mod_id: String) -> Dictionary:
# Check if the mod ID is invalid
if not ModLoaderStore.mod_data.has(mod_id):
ModLoaderLog.fatal("Mod ID \"%s\" not found" % [mod_id], LOG_NAME)
return {}
var config_dictionary: Dictionary = ModLoaderStore.mod_data[mod_id].configs
# Check if there is no config file for the mod
if config_dictionary.is_empty():
ModLoaderLog.debug("No config for mod id \"%s\"" % mod_id, LOG_NAME, true)
return {}
return config_dictionary
## Retrieves the configuration for a specific mod and configuration name.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param config_name] ([String]): The name of the configuration.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The configuration as a [ModConfig] object or null if not found.
static func get_config(mod_id: String, config_name: String) -> ModConfig:
var configs := get_configs(mod_id)
if not configs.has(config_name):
ModLoaderLog.error("No config with name \"%s\" found for mod_id \"%s\" " % [config_name, mod_id], LOG_NAME)
return null
return configs[config_name]
## Checks whether a mod has a current configuration set.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if the mod has a current configuration, false otherwise.
static func has_current_config(mod_id: String) -> bool:
var mod_data := ModLoaderMod.get_mod_data(mod_id)
return not mod_data.current_config == null
## Checks whether a mod has a configuration with the specified name.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param config_name] ([String]): The name of the configuration.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if the mod has a configuration with the specified name, False otherwise.
static func has_config(mod_id: String, config_name: String) -> bool:
var mod_data := ModLoaderMod.get_mod_data(mod_id)
return mod_data.configs.has(config_name)
## Retrieves the default configuration for a specified mod ID.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The [ModConfig] object representing the default configuration for the specified mod.
## If the mod ID is invalid or no configuration is found, returns null.
static func get_default_config(mod_id: String) -> ModConfig:
return get_config(mod_id, DEFAULT_CONFIG_NAME)
## Retrieves the currently active configuration for a specific mod.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The configuration as a [ModConfig] object or [code]null[/code] if not found.
static func get_current_config(mod_id: String) -> ModConfig:
var current_config_name := get_current_config_name(mod_id)
var current_config: ModConfig
# Load the default configuration if there is no configuration set as current yet
# Otherwise load the corresponding configuration
if current_config_name.is_empty():
current_config = get_default_config(mod_id)
else:
current_config = get_config(mod_id, current_config_name)
return current_config
## Retrieves the name of the current configuration for a specific mod.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## [br]
## [b]Returns:[/b][br]
## - [String] The currently active configuration name for the given mod id or an empty string if not found.
static func get_current_config_name(mod_id: String) -> String:
# Check if user profile has been loaded
if not ModLoaderStore.current_user_profile or not ModLoaderStore.user_profiles.has(ModLoaderStore.current_user_profile.name):
# Warn and return an empty string if the user profile has not been loaded
ModLoaderLog.warning("Can't get current mod config name for \"%s\", because no current user profile is present." % mod_id, LOG_NAME)
return ""
# Retrieve the current user profile from ModLoaderStore
# *Can't use ModLoaderUserProfile because it causes a cyclic dependency*
var current_user_profile = ModLoaderStore.current_user_profile
# Check if the mod exists in the user profile's mod list and if it has a current config
if not current_user_profile.mod_list.has(mod_id) or not current_user_profile.mod_list[mod_id].has("current_config"):
# Log an error and return an empty string if the mod has no config file
ModLoaderLog.error("Can't get current mod config name for \"%s\" because no config file exists." % mod_id, LOG_NAME)
return ""
# Return the name of the current configuration for the mod
return current_user_profile.mod_list[mod_id].current_config
## Refreshes the data of the provided configuration by reloading it from the config file.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param config] ([ModConfig]): The [ModConfig] object whose data needs to be refreshed.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModConfig]: The [ModConfig] object with refreshed data if successful, or the original object otherwise.
static func refresh_config_data(config: ModConfig) -> ModConfig:
# Retrieve updated configuration data from the config file
var new_config_data := _ModLoaderFile.get_json_as_dict(config.save_path)
# Update the data property of the ModConfig object with the refreshed data
config.data = new_config_data
return config
## Iterates over all mods to refresh the data of their current configurations, if available.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## Compares the previous configuration data with the refreshed data and emits the [signal ModLoader.current_config_changed]
## signal if changes are detected.[br]
## This function ensures that any changes made to the configuration files outside the application
## are reflected within the application's runtime, allowing for dynamic updates without the need for a restart.
static func refresh_current_configs() -> void:
for mod_id in ModLoaderMod.get_mod_data_all().keys():
# Skip if the mod has no config
if not has_current_config(mod_id):
return
# Retrieve the current configuration for the mod
var config := get_current_config(mod_id)
# Create a deep copy of the current configuration data for comparison
var config_data_previous := config.data.duplicate(true)
# Refresh the configuration data
var config_new := refresh_config_data(config)
# Compare previous data with refreshed data
if not config_data_previous == config_new.data:
# Emit signal indicating that the current configuration has changed
ModLoader.current_config_changed.emit(config)

View File

@@ -0,0 +1 @@
uid://byhbvq7il70cy

View File

@@ -0,0 +1,76 @@
class_name ModLoaderDeprecated
extends Object
##
## API methods for deprecating funcs. Can be used by mods with public APIs.
const LOG_NAME := "ModLoader:Deprecated"
## Marks a method that has changed its name or class.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param old_method] ([String]): The name of the deprecated method.[br]
## - [param new_method] ([String]): The name of the new method to use.[br]
## - [param since_version] ([String]): The version number from which the method has been deprecated.[br]
## - [param show_removal_note] ([bool]): (optional) If true, includes a note about future removal of the old method. Default is true.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func deprecated_changed(old_method: String, new_method: String, since_version: String, show_removal_note: bool = true) -> void:
_deprecated_log(str(
"DEPRECATED: ",
"The method \"%s\" has been deprecated since version %s. " % [old_method, since_version],
"Please use \"%s\" instead. " % new_method,
"The old method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else ""
))
## Marks a method that has been entirely removed, with no replacement.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param old_method] ([String]): The name of the removed method.[br]
## - [param since_version] ([String]): The version number from which the method has been deprecated.[br]
## - [param show_removal_note] ([bool]): (optional) If true, includes a note about future removal of the old method. Default is true.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[/b][br]
## This should rarely be needed but is included for completeness.[br]
## ===[br]
static func deprecated_removed(old_method: String, since_version: String, show_removal_note: bool = true) -> void:
_deprecated_log(str(
"DEPRECATED: ",
"The method \"%s\" has been deprecated since version %s, and is no longer available. " % [old_method, since_version],
"There is currently no replacement method. ",
"The method will be removed with the next major update, and will break your code if not changed. " if show_removal_note else ""
))
## Marks a method with a freeform deprecation message.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param msg] ([String]): The deprecation message.[br]
## - [param since_version] ([String]): (optional) The version number from which the deprecation applies.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func deprecated_message(msg: String, since_version: String = "") -> void:
var since_text := " (since version %s)" % since_version if since_version else ""
_deprecated_log(str("DEPRECATED: ", msg, since_text))
# Internal function for logging deprecation messages with support to trigger warnings instead of fatal errors.[br]
# [br]
# [b]Parameters:[/b][br]
# - [param msg] ([String]): The deprecation message.[br]
# [br]
# [b]Returns:[/b][br]
# - No return value[br]
static func _deprecated_log(msg: String) -> void:
if ModLoaderStore and ModLoaderStore.ml_options.ignore_deprecated_errors or OS.has_feature("standalone"):
ModLoaderLog.warning(msg, LOG_NAME, true)
else:
ModLoaderLog.fatal(msg, LOG_NAME, true)

View File

@@ -0,0 +1 @@
uid://2sifoxblubxv

View File

@@ -0,0 +1,83 @@
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: Array[Callable] = []
var _callback_index := -1
const LOG_NAME := "ModLoaderHookChain"
# `callbacks` is kept as untyped Array for simplicity when creating a new chain.
# This approach allows direct use of `[vanilla_method] + hooks` without the need to cast types with Array.assign().
func _init(reference_object: Object, callbacks: Array) -> void:
self.reference_object = reference_object
_callbacks.assign(callbacks)
_callback_index = callbacks.size()
## Will execute the next mod hook callable or vanilla method and return the result.[br]
## [br]
## [br][b]Parameters:[/b][br]
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
## [br]
## [br][b]Returns:[/b][br]
## - [Variant]: Return value of the next function in the chain.[br]
## [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]
func execute_next(args := []) -> Variant:
var callback := _get_next_callback()
if not callback:
return
# Vanilla needs to be called without the hook chain being passed
if _is_callback_vanilla():
return callback.callv(args)
return callback.callv([self] + args)
## Same as [method execute_next], but asynchronous - it can be used if a method uses [code]await[/code]. [br]
## [br]
## [br][b]Parameters:[/b][br]
## - [param args] ([Array]): An array of all arguments passed into the vanilla function. [br]
## [br]
## [br][b]Returns:[/b][br]
## - [Variant]: Return value of the next function in the chain.[br]
## [br]
## This hook needs to be used if the vanilla method uses [code]await[/code] somewhere. [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]
func execute_next_async(args := []) -> Variant:
var callback := _get_next_callback()
if not callback:
return
# Vanilla needs to be called without the hook chain being passed
if _is_callback_vanilla():
return await callback.callv(args)
return await callback.callv([self] + args)
func _get_next_callback() -> 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
return _callbacks[_callback_index]
func _is_callback_vanilla() -> bool:
return _callback_index == 0

View File

@@ -0,0 +1 @@
uid://nyep44jvp7yc

View File

@@ -0,0 +1,599 @@
@tool
class_name ModLoaderLog
extends Object
##
## This Class provides methods for logging, retrieving logged data, and internal methods for working with log files.
# Path to the latest log file.
const MOD_LOG_PATH := "user://logs/modloader.log"
const _LOG_NAME := "ModLoader:Log"
## Denotes the severity of a log entry
enum VERBOSITY_LEVEL {
ERROR, ## For errors and fatal errors
WARNING, ## For warnings
INFO, ## For everything informational and successes
DEBUG, ## For debugging, can get quite verbose
}
## Keeps track of logged messages, to avoid flooding the log with duplicate notices
## Can also be used by mods, eg. to create an in-game developer console that
## shows messages
static var logged_messages := {
"all": {},
"by_mod": {},
"by_type": {
"fatal-error": {},
"error": {},
"warning": {},
"info": {},
"success": {},
"debug": {},
"hint": {},
}
}
## Verbosity/Logging level.
## Used to filter out messages below the set level
## (if the [enum VERBOSITY_LEVEL] int of a new entry is larger than the [member verbosity] it is ignored)
static var verbosity: VERBOSITY_LEVEL = VERBOSITY_LEVEL.DEBUG
## Array of mods that should be ignored when logging messages (contains mod IDs as strings)
static var ignored_mods: Array[String] = []
## Highlighting color for hint type log messages
static var hint_color := Color("#70bafa")
## This Sub-Class represents a log entry in ModLoader.
class ModLoaderLogEntry:
extends Resource
## Name of the mod or ModLoader class this entry refers to.
var mod_name: String
## The message of the log entry.
var message: String
## The log type, which indicates the verbosity level of this entry.
var type: String
## The readable format of the time when this log entry was created.
## Used for printing in the log file and output.
var time: String
## The timestamp when this log entry was created.
## Used for comparing and sorting log entries by time.
var time_stamp: int
## An array of ModLoaderLogEntry objects.
## If the message has been logged before, it is added to the stack.
var stack := []
## Initialize a ModLoaderLogEntry object with provided values.[br]
##[br]
## [b]Parameters:[/b][br]
## [param _mod_name] ([String]): Name of the mod or ModLoader class this entry refers to.[br]
## [param _message] ([String]): The message of the log entry.[br]
## [param _type] ([String]): The log type, which indicates the verbosity level of this entry.[br]
## [param _time] ([String]): The readable format of the time when this log entry was created.[br]
##[br]
## [b]Returns:[/b][br]
## - No return value[br]
func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void:
mod_name = _mod_name
message = _message
type = _type
time = _time
time_stamp = Time.get_ticks_msec()
## Get the log entry as a formatted string.[br]
## [br]
## [b]Returns:[/b] [String]
func get_entry() -> String:
return str(time, get_prefix(), message)
## Get the prefix string for the log entry, including the log type and mod name.[br]
## [br]
## [b]Returns:[/b] [String]
func get_prefix() -> String:
return "%s %s: " % [type.to_upper(), mod_name]
## Generate an MD5 hash of the log entry (prefix + message).[br]
## [br]
## [b]Returns:[/b] [String]
func get_md5() -> String:
return str(get_prefix(), message).md5_text()
## Get all log entries, including the current entry and entries in the stack.[br]
## [br]
## [b]Returns:[/b] [Array]
func get_all_entries() -> Array:
var entries := [self]
entries.append_array(stack)
return entries
# API log functions - logging
# =============================================================================
## Logs the error in red and a stack trace. Prefixed FATAL-ERROR.[br]
## Always logged.[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as an error.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[color=bug "Breakpoint"][/color][/b][br]
## Stops execution in the editor, use this when something really needs to be fixed.[br]
## ===[br]
static func fatal(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "fatal-error", only_once)
## Logs the message and pushes an error. Prefixed ERROR.[br]
## Always logged.[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as an error.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func error(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "error", only_once)
## Logs the message and pushes a warning. Prefixed WARNING.[br]
## Logged with verbosity level at or above warning ([code]-v[/code] or [code]--log-warning[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a warning.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func warning(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "warning", only_once)
## Logs the message. Prefixed INFO.[br]
## Logged with verbosity level at or above info ([code]-vv[/code] or [code]--log-info[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as an information.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func info(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "info", only_once)
## Logs the message. Prefixed SUCCESS.[br]
## Logged with verbosity level at or above info ([code]-vv[/code] or [code]--log-info[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a success.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func success(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "success", only_once)
## Logs the message. Prefixed DEBUG.[br]
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a debug.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func debug(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "debug", only_once)
## Logs the message. Prefixed HINT and highligted.[br]
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]) and in the editor only. Not written to mod loader log.[br]
## ===[br]
## [b]Note:[/b][br]
## Use this to help other developers debug issues by giving them error-specific hints.[br]
## ===[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a debug.[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func hint(message: String, mod_name: String, only_once := false) -> void:
_log(message, mod_name, "hint", only_once)
## Logs the message formatted with [method JSON.print]. Prefixed DEBUG.[br]
## Logged with verbosity level at or above debug ([code]-vvv[/code] or [code]--log-debug[/code]).[br]
## [br]
## [b]Parameters:[/b][br]
## [param message] ([String]): The message to be logged as a debug.[br]
## [param json_printable] (Variant): The variable to be formatted and printed using [method JSON.print].[br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with this log entry.[br]
## [param only_once] ([bool]): (Optional) If true, the log entry will only be logged once, even if called multiple times. Default is false.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
static func debug_json_print(message: String, json_printable, mod_name: String, only_once := false) -> void:
message = "%s\n%s" % [message, JSON.stringify(json_printable, " ")]
_log(message, mod_name, "debug", only_once)
# API log functions - stored logs
# =============================================================================
## Returns an array of log entries as a resource.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as resource.
static func get_all_as_resource() -> Array:
return get_all()
## Returns an array of log entries as a string.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings.
static func get_all_as_string() -> Array:
var log_entries := get_all()
return get_all_entries_as_string(log_entries)
## Returns an array of log entries as a resource for a specific mod_name.[br]
## [br]
## [b]Parameters:[/b][br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as resource for the specified [code]mod_name[/code].
static func get_by_mod_as_resource(mod_name: String) -> Array:
return get_by_mod(mod_name)
## Returns an array of log entries as a string for a specific mod_name.[br]
## [br]
## [b]Parameters:[/b][br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings for the specified [code]mod_name[/code].
static func get_by_mod_as_string(mod_name: String) -> Array:
var log_entries := get_by_mod(mod_name)
return get_all_entries_as_string(log_entries)
## Returns an array of log entries as a resource for a specific type.[br]
## [br]
## [b]Parameters:[/b][br]
## [param type] ([String]): The log type associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as resource for the specified [code]type[/code].
static func get_by_type_as_resource(type: String) -> Array:
return get_by_type(type)
## Returns an array of log entries as a string for a specific type.[br]
## [br]
## [b]Parameters:[/b][br]
## [param type] ([String]): The log type associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings for the specified [code]type[/code].
static func get_by_type_as_string(type: String) -> Array:
var log_entries := get_by_type(type)
return get_all_entries_as_string(log_entries)
## Returns an array of all log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of all log entries.
static func get_all() -> Array:
var log_entries := []
# Get all log entries
for entry_key in logged_messages.all.keys():
var entry: ModLoaderLogEntry = logged_messages.all[entry_key]
log_entries.append_array(entry.get_all_entries())
# Sort them by time
log_entries.sort_custom(Callable(ModLoaderLogCompare, "time"))
return log_entries
## Returns an array of log entries for a specific mod_name.[br]
## [br]
## [b]Parameters:[/b][br]
## [param mod_name] ([String]): The name of the mod or ModLoader class associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries for the specified [code]mod_name[/code].
static func get_by_mod(mod_name: String) -> Array:
var log_entries := []
if not logged_messages.by_mod.has(mod_name):
error("\"%s\" not found in logged messages." % mod_name, _LOG_NAME)
return []
for entry_key in logged_messages.by_mod[mod_name].keys():
var entry: ModLoaderLogEntry = logged_messages.by_mod[mod_name][entry_key]
log_entries.append_array(entry.get_all_entries())
return log_entries
## Returns an array of log entries for a specific type.[br]
## [br]
## [b]Parameters:[/b][br]
## [param type] ([String]): The log type associated with the log entries.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries for the specified [code]type[/code].
static func get_by_type(type: String) -> Array:
var log_entries := []
for entry_key in logged_messages.by_type[type].keys():
var entry: ModLoaderLogEntry = logged_messages.by_type[type][entry_key]
log_entries.append_array(entry.get_all_entries())
return log_entries
## Returns an array of log entries represented as strings.[br]
## [br]
## [b]Parameters:[/b][br]
## [param log_entries] ([Array]): An array of ModLoaderLogEntry Objects.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: An array of log entries represented as strings.
static func get_all_entries_as_string(log_entries: Array) -> Array:
var log_entry_strings := []
# Get all the strings
for entry in log_entries:
log_entry_strings.push_back(entry.get_entry())
return log_entry_strings
# Internal log functions
# =============================================================================
static func _log(message: String, mod_name: String, log_type: String = "info", only_once := false) -> void:
if _is_mod_name_ignored(mod_name):
return
var time := "%s " % _get_time_string()
var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time)
if only_once and _is_logged_before(log_entry):
return
_store_log(log_entry)
# Check if the scene_tree is available
if Engine.get_main_loop() and ModLoader:
ModLoader.emit_signal("logged", log_entry)
_code_note(str(
"If you are seeing this after trying to run the game, there is an error in your mod somewhere.",
"Check the Debugger tab (below) to see the error.",
"Click through the files listed in Stack Frames to trace where the error originated.",
"View Godot's documentation for more info:",
"https://docs.godotengine.org/en/stable/tutorials/scripting/debug/debugger_panel.html#doc-debugger-panel"
))
match log_type.to_lower():
"fatal-error":
push_error(message)
_write_to_log_file(log_entry.get_entry())
_write_to_log_file(JSON.stringify(get_stack(), " "))
assert(false, message)
"error":
printerr(log_entry.get_prefix() + message)
push_error(message)
_write_to_log_file(log_entry.get_entry())
"warning":
if verbosity >= VERBOSITY_LEVEL.WARNING:
print(log_entry.get_prefix() + message)
push_warning(message)
_write_to_log_file(log_entry.get_entry())
"info", "success":
if verbosity >= VERBOSITY_LEVEL.INFO:
print(log_entry.get_prefix() + message)
_write_to_log_file(log_entry.get_entry())
"debug":
if verbosity >= VERBOSITY_LEVEL.DEBUG:
print(log_entry.get_prefix() + message)
_write_to_log_file(log_entry.get_entry())
"hint":
if OS.has_feature("editor") and verbosity >= VERBOSITY_LEVEL.DEBUG:
print_rich("[color=%s]%s[/color]" % [hint_color.to_html(false), log_entry.get_prefix() + message])
static func _is_mod_name_ignored(mod_name: String) -> bool:
if ignored_mods.is_empty():
return false
if mod_name in ignored_mods:
return true
return false
static func _store_log(log_entry: ModLoaderLogEntry) -> void:
var existing_entry: ModLoaderLogEntry
# Store in all
# If it's a new entry
if not logged_messages.all.has(log_entry.get_md5()):
logged_messages.all[log_entry.get_md5()] = log_entry
# If it's a existing entry
else:
existing_entry = logged_messages.all[log_entry.get_md5()]
existing_entry.time = log_entry.time
existing_entry.stack.push_back(log_entry)
# Store in by_mod
# If the mod is not yet in "by_mod" init the entry
if not logged_messages.by_mod.has(log_entry.mod_name):
logged_messages.by_mod[log_entry.mod_name] = {}
logged_messages.by_mod[log_entry.mod_name][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry
# Store in by_type
logged_messages.by_type[log_entry.type.to_lower()][log_entry.get_md5()] = log_entry if not existing_entry else existing_entry
static func _is_logged_before(entry: ModLoaderLogEntry) -> bool:
if not logged_messages.all.has(entry.get_md5()):
return false
return true
class ModLoaderLogCompare:
# Custom sorter that orders logs by time
static func time(a: ModLoaderLogEntry, b: ModLoaderLogEntry) -> bool:
if a.time_stamp > b.time_stamp:
return true # a -> b
else:
return false # b -> a
# Internal Date Time
# =============================================================================
# Returns the current time as a string in the format hh:mm:ss
static func _get_time_string() -> String:
var date_time := Time.get_datetime_dict_from_system()
return "%02d:%02d:%02d" % [ date_time.hour, date_time.minute, date_time.second ]
# Returns the current date as a string in the format yyyy-mm-dd
static func _get_date_string() -> String:
var date_time := Time.get_datetime_dict_from_system()
return "%s-%02d-%02d" % [ date_time.year, date_time.month, date_time.day ]
# Returns the current date and time as a string in the format yyyy-mm-dd_hh:mm:ss
static func _get_date_time_string() -> String:
return "%s_%s" % [ _get_date_string(), _get_time_string() ]
# Internal File
# =============================================================================
static func _write_to_log_file(string_to_write: String) -> void:
if not FileAccess.file_exists(MOD_LOG_PATH):
_rotate_log_file()
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.READ_WRITE)
if log_file == null:
assert(false, "Could not open log file, error code: %s" % error)
return
log_file.seek_end()
log_file.store_string("\n" + string_to_write)
log_file.close()
# Keeps log backups for every run, just like the Godot gdscript implementation of
# https://github.com/godotengine/godot/blob/1d14c054a12dacdc193b589e4afb0ef319ee2aae/core/io/logger.cpp#L151
static func _rotate_log_file() -> void:
var MAX_LOGS: int = ProjectSettings.get_setting("debug/file_logging/max_log_files")
if FileAccess.file_exists(MOD_LOG_PATH):
if MAX_LOGS > 1:
var datetime := _get_date_time_string().replace(":", ".")
var backup_name: String = MOD_LOG_PATH.get_basename() + "_" + datetime
if MOD_LOG_PATH.get_extension().length() > 0:
backup_name += "." + MOD_LOG_PATH.get_extension()
var dir := DirAccess.open(MOD_LOG_PATH.get_base_dir())
if not dir == null:
dir.copy(MOD_LOG_PATH, backup_name)
_clear_old_log_backups()
# only File.WRITE creates a new file, File.READ_WRITE throws an error
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.WRITE)
if log_file == null:
assert(false, "Could not open log file, error code: %s" % error)
log_file.store_string('%s Created log' % _get_date_string())
log_file.close()
static func _clear_old_log_backups() -> void:
var MAX_LOGS := int(ProjectSettings.get_setting("debug/file_logging/max_log_files"))
var MAX_BACKUPS := MAX_LOGS - 1 # -1 for the current new log (not a backup)
var basename := MOD_LOG_PATH.get_file().get_basename() as String
var extension := MOD_LOG_PATH.get_extension() as String
var dir := DirAccess.open(MOD_LOG_PATH.get_base_dir())
if dir == null:
return
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
var file := dir.get_next()
var backups := []
while file.length() > 0:
if (not dir.current_is_dir() and
file.begins_with(basename) and
file.get_extension() == extension and
not file == MOD_LOG_PATH.get_file()):
backups.append(file)
file = dir.get_next()
dir.list_dir_end()
if backups.size() > MAX_BACKUPS:
backups.sort()
backups.resize(backups.size() - MAX_BACKUPS)
for file_to_delete in backups:
dir.remove(file_to_delete)
# Internal util funcs
# =============================================================================
# This are duplicates of the functions in mod_loader_utils.gd to prevent
# a cyclic reference error between ModLoaderLog and ModLoaderUtils.
# This is a dummy func. It is exclusively used to show notes in the code that
# stay visible after decompiling a PCK, as is primarily intended to assist new
# modders in understanding and troubleshooting issues.
static func _code_note(_msg:String):
pass

View File

@@ -0,0 +1 @@
uid://dfoleo2pforxu

View File

@@ -0,0 +1,358 @@
class_name ModLoaderMod
extends Object
##
## This Class provides helper functions to build mods.
##
## @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]
## [br]
## [b]Parameters:[/b][br]
## - [param child_script_path] ([String]): The path to the mod's extender script.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [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]
## ===[br]
## [b]Note:[/b][br]
## Your extender script doesn't have to follow the same directory path as the vanilla file,
## but it's good practice to do so.[br]
## ===[br]
## [br]
static func install_script_extension(child_script_path: String) -> void:
var mod_id: String = _ModLoaderPath.get_mod_dir(child_script_path)
var mod_data: ModData = get_mod_data(mod_id)
if not ModLoaderStore.saved_extension_paths.has(mod_data.manifest.get_mod_id()):
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)
## Adds all methods from a file as hooks. [br]
## [br]
## [b]Parameters:[/b][br]
## - [param vanilla_script_path] ([String]): The path to the script which will be hooked.[br]
## - [param hook_script_path] ([String]): The path to the script containing hooks.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [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]
## [br]
## [b]Examples:[/b][br]
## [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: String = vanilla_methods.front()
if closest_vanilla.similarity(hook.name) > 0.8:
ModLoaderLog.hint(
'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]
## [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][br][br]
## - No return value[br]
## [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]Examples:[/b][br]
## [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]
## [br]
## [b]Parameters:[/b][br]
## - [param new_global_classes] ([Array]): An array of class definitions to be registered.[br]
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## Format: [code]{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }[/code][br]
## [br]
## ===[br]
## [b]Tip:[/b][color=tip][/color][br]
## You can find these easily in the project.godot file under `_global_script_classes`[br]
## (but you should only include classes belonging to your mod)[br]
## ===[br]
static func register_global_classes_from_array(new_global_classes: Array) -> void:
ModLoaderUtils.register_global_classes_from_array(new_global_classes)
var _savecustom_error: int = ProjectSettings.save_custom(_ModLoaderPath.get_override_path())
## Adds a translation file.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param resource_path] ([String]): The path to the translation resource file.[br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[/b][br]
## The [code].translation[/code] file should have been created by the Godot editor already, usually when importing a CSV file.
## The translation file should named [code]name.langcode.translation[/code] -> [code]mytranslation.en.translation[/code].[br]
## ===[br]
static func add_translation(resource_path: String) -> void:
if not _ModLoaderFile.file_exists(resource_path):
ModLoaderLog.fatal("Tried to load a position resource from a file that doesn't exist. The invalid path was: %s" % [resource_path], LOG_NAME)
return
var translation_object: Translation = load(resource_path)
if translation_object:
TranslationServer.add_translation(translation_object)
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)
## Marks the given scene for to be refreshed. It will be refreshed at the correct point in time later.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param scene_path] ([String]): The path to the scene file to be refreshed.
## [br]
## [b]Returns:[/b][br]
## - No return value[br]
## [br]
## ===[br]
## [b]Note:[/b][color=abstract "Version"][/color][br]
## This function requires Godot 4.3 or higher.[br]
## ===[br]
## [br]
## This function is useful if a script extension is not automatically applied.
## This situation can occur when a script is attached to a preloaded scene.
## If you encounter issues where your script extension is not working as expected,
## try to identify the scene to which it is attached and use this method to refresh it.
## This will reload already loaded scenes and apply the script extension.
## [br]
static func refresh_scene(scene_path: String) -> void:
if scene_path in ModLoaderStore.scenes_to_refresh:
return
ModLoaderStore.scenes_to_refresh.push_back(scene_path)
ModLoaderLog.debug("Added \"%s\" to be refreshed." % scene_path, LOG_NAME)
## Extends a specific scene by providing a callable function to modify it.
## [br]
## [b]Parameters:[/b][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][br]
## - No return value[br]
## [br]
## The callable receives an instance of the "vanilla_scene" as the first parameter.[br]
static func extend_scene(scene_vanilla_path: String, edit_callable: Callable) -> void:
if not ModLoaderStore.scenes_to_modify.has(scene_vanilla_path):
ModLoaderStore.scenes_to_modify[scene_vanilla_path] = []
ModLoaderStore.scenes_to_modify[scene_vanilla_path].push_back(edit_callable)
## Gets the [ModData] from the provided namespace.[br]
## [br]
## [b]Parameters:[/b][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]
static func get_mod_data(mod_id: String) -> ModData:
if not ModLoaderStore.mod_data.has(mod_id):
ModLoaderLog.error("%s is an invalid mod_id" % mod_id, LOG_NAME)
return null
return ModLoaderStore.mod_data[mod_id]
## Gets the [ModData] of all loaded Mods as [Dictionary].[br]
## [br]
## [b]Returns:[/b][br]
## - [Dictionary]: A dictionary containing the [ModData] of all loaded mods.[br]
static func get_mod_data_all() -> Dictionary:
return ModLoaderStore.mod_data
## Returns the path to the directory where unpacked mods are stored.[br]
## [br]
## [b]Returns:[/b][br]
## - [String]: The path to the unpacked mods directory.[br]
static func get_unpacked_dir() -> String:
return _ModLoaderPath.get_unpacked_mods_dir_path()
## Returns true if the mod with the given [code]mod_id[/code] was successfully loaded.[br]
## [br]
## [b]Parameters:[/b][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]
static func is_mod_loaded(mod_id: String) -> bool:
if ModLoaderStore.is_initializing:
ModLoaderLog.warning(
"The ModLoader is not fully initialized. " +
"Calling \"is_mod_loaded()\" in \"_init()\" may result in an unexpected return value as mods are still loading.",
LOG_NAME
)
# If the mod is not present in the mod_data dictionary or the mod is flagged as not loadable.
if not ModLoaderStore.mod_data.has(mod_id) or not ModLoaderStore.mod_data[mod_id].is_loadable:
return false
return true
## Returns true if the mod with the given mod_id was successfully loaded and is currently active.
## [br]
## Parameters:
## - [param mod_id] ([String]): The ID of the mod.
## [br]
## Returns:
## - [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

View File

@@ -0,0 +1 @@
uid://d2hugw88f3q4e

View File

@@ -0,0 +1,475 @@
class_name ModLoaderUserProfile
extends Object
##
## This Class provides methods for working with user profiles.
const LOG_NAME := "ModLoader:UserProfile"
# The path where the Mod User Profiles data is stored.
const FILE_PATH_USER_PROFILES := "user://mod_user_profiles.json"
# API profile functions
# =============================================================================
## Enables a mod - it will be loaded on the next game start[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod to enable.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to enable the mod for. Default is the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func enable_mod(mod_id: String, user_profile:= ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, user_profile.name, true)
## Forces a mod to enable, ensuring it loads at the next game start, regardless of load warnings.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod to enable.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile for which the mod will be enabled. Defaults to the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func force_enable_mod(mod_id: String, user_profile:= ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, user_profile.name, true, true)
## Disables a mod - it will not be loaded on the next game start[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod to disable.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to disable the mod for. Default is the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func disable_mod(mod_id: String, user_profile := ModLoaderStore.current_user_profile) -> bool:
return _set_mod_state(mod_id, user_profile.name, false)
## Sets the current config for a mod in a user profile's mod_list.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param mod_id] ([String]): The ID of the mod.[br]
## - [param mod_config] ([ModConfig]): The mod config to set as the current config.[br]
## - [param user_profile] ([ModUserProfile]): (Optional) The user profile to update. Default is the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func set_mod_current_config(mod_id: String, mod_config: ModConfig, user_profile := ModLoaderStore.current_user_profile) -> bool:
# Verify whether the mod_id is present in the profile's mod_list.
if not _is_mod_id_in_mod_list(mod_id, user_profile.name):
return false
# Update the current config in the mod_list of the user profile
user_profile.mod_list[mod_id].current_config = mod_config.name
# Store the new profile in the json file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Set the \"current_config\" of \"%s\" to \"%s\" in user profile \"%s\" " % [mod_id, mod_config.name, user_profile.name], LOG_NAME)
return is_save_success
## Creates a new user profile with the given name, using the currently loaded mods as the mod list.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param profile_name] ([String]): The name of the new user profile (must be unique).[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func create_profile(profile_name: String) -> bool:
# Verify that the profile name is not already in use
if ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with the name of \"%s\" already exists." % profile_name, LOG_NAME)
return false
var mod_list := _generate_mod_list()
var new_profile := _create_new_profile(profile_name, mod_list)
# If there was an error creating the new user profile return
if not new_profile:
return false
# Store the new profile in the ModLoaderStore
ModLoaderStore.user_profiles[profile_name] = new_profile
# Set it as the current profile
ModLoaderStore.current_user_profile = new_profile
# Store the new profile in the json file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Created new user profile \"%s\"" % profile_name, LOG_NAME)
return is_save_success
## Sets the current user profile to the given user profile.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param user_profile] ([ModUserProfile]): The user profile to set as the current profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func set_profile(user_profile: ModUserProfile) -> bool:
# Check if the profile name is unique
if not ModLoaderStore.user_profiles.has(user_profile.name):
ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME)
return false
# Update the current_user_profile in the ModLoaderStore
ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[user_profile.name]
# Save changes in the json file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Current user profile set to \"%s\"" % user_profile.name, LOG_NAME)
return is_save_success
## Deletes the given user profile.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param user_profile] ([ModUserProfile]): The user profile to delete.[br]
## [br]
## [b]Returns:[/b][br]
## - [bool]: True on success.
static func delete_profile(user_profile: ModUserProfile) -> bool:
# If the current_profile is about to get deleted log an error
if ModLoaderStore.current_user_profile.name == user_profile.name:
ModLoaderLog.error(str(
"You cannot delete the currently selected user profile \"%s\" " +
"because it is currently in use. Please switch to a different profile before deleting this one.") % user_profile.name,
LOG_NAME)
return false
# Deleting the default profile is not allowed
if user_profile.name == "default":
ModLoaderLog.error("You can't delete the default profile", LOG_NAME)
return false
# Delete the user profile
if not ModLoaderStore.user_profiles.erase(user_profile.name):
# Erase returns false if the the key is not present in user_profiles
ModLoaderLog.error("User profile with name \"%s\" not found." % user_profile.name, LOG_NAME)
return false
# Save profiles to the user profiles JSON file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Deleted user profile \"%s\"" % user_profile.name, LOG_NAME)
return is_save_success
## Returns the current user profile.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModUserProfile]: The current profile or [code]null[/code] if not set.
static func get_current() -> ModUserProfile:
return ModLoaderStore.current_user_profile
## Returns the user profile with the given name.[br]
## [br]
## [b]Parameters:[/b][br]
## - [param profile_name] ([String]): The name of the user profile to retrieve.[br]
## [br]
## [b]Returns:[/b][br]
## - [ModUserProfile]: The profile or [code]null[/code] if not found
static func get_profile(profile_name: String) -> ModUserProfile:
if not ModLoaderStore.user_profiles.has(profile_name):
ModLoaderLog.error("User profile with name \"%s\" not found." % profile_name, LOG_NAME)
return null
return ModLoaderStore.user_profiles[profile_name]
## Returns an array containing all user profiles stored in ModLoaderStore.[br]
## [br]
## [b]Returns:[/b][br]
## - [Array]: A list of [ModUserProfile] Objects
static func get_all_as_array() -> Array:
var user_profiles := []
for user_profile_name in ModLoaderStore.user_profiles.keys():
user_profiles.push_back(ModLoaderStore.user_profiles[user_profile_name])
return user_profiles
## Returns true if the Mod User Profiles are initialized.
## [br]
## [b]Returns:[/b][br]
## - [bool]: True if profiles are ready.
## [br]
## On the first execution of the game, user profiles might not yet be created.
## Use this method to check if everything is ready to interact with the ModLoaderUserProfile API.
static func is_initialized() -> bool:
return _ModLoaderFile.file_exists(FILE_PATH_USER_PROFILES)
# Internal profile functions
# =============================================================================
# Update the global list of disabled mods based on the current user profile
# The user profile will override the disabled_mods property that can be set via the options resource in the editor.
# Example: If "Mod-TestMod" is set in disabled_mods via the editor, the mod will appear disabled in the user profile.
# If the user then enables the mod in the profile the entry in disabled_mods will be removed.
static func _update_disabled_mods() -> void:
var current_user_profile: ModUserProfile = get_current()
# Check if a current user profile is set
if not current_user_profile:
ModLoaderLog.info("There is no current user profile. The \"default\" profile will be created.", LOG_NAME)
return
# Iterate through the mod list in the current user profile to find disabled mods
for mod_id in current_user_profile.mod_list:
var mod_list_entry: Dictionary = current_user_profile.mod_list[mod_id]
if ModLoaderStore.mod_data.has(mod_id):
ModLoaderStore.mod_data[mod_id].set_mod_state(mod_list_entry.is_active, true)
ModLoaderLog.debug(
"Updated the active state of all mods, based on the current user profile \"%s\""
% current_user_profile.name,
LOG_NAME)
# This function updates the mod lists of all user profiles with newly loaded mods that are not already present.
# It does so by comparing the current set of loaded mods with the mod list of each user profile, and adding any missing mods.
# Additionally, it checks for and deletes any mods from each profile's mod list that are no longer installed on the system.
static func _update_mod_lists() -> bool:
# Generate a list of currently present mods by combining the mods
# in mod_data and ml_options.disabled_mods from ModLoaderStore.
var current_mod_list := _generate_mod_list()
# Iterate over all user profiles
for profile_name in ModLoaderStore.user_profiles.keys():
var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name]
# Merge the profiles mod_list with the previously created current_mod_list
profile.mod_list.merge(current_mod_list)
var update_mod_list := _update_mod_list(profile.mod_list)
profile.mod_list = update_mod_list
# Save the updated user profiles to the JSON file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Updated the mod lists of all user profiles", LOG_NAME)
return is_save_success
# This function takes a mod_list dictionary and optional mod_data dictionary as input and returns
# an updated mod_list dictionary. It iterates over each mod ID in the mod list, checks if the mod
# is still installed and if the current_config is present. If the mod is not installed or the current
# config is missing, the mod is removed or its current_config is reset to the default configuration.
static func _update_mod_list(mod_list: Dictionary, mod_data := ModLoaderStore.mod_data) -> Dictionary:
var updated_mod_list := mod_list.duplicate(true)
# Iterate over each mod ID in the mod list
for mod_id in updated_mod_list.keys():
var mod_list_entry: Dictionary = updated_mod_list[mod_id]
# Check if the current config doesn't exist
# This can happen if the config file was manually deleted
if mod_list_entry.has("current_config") and _ModLoaderPath.get_path_to_mod_config_file(mod_id, mod_list_entry.current_config).is_empty():
# If the current config doesn't exist, reset it to the default configuration
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME
if (
# If the mod is not loaded
not mod_data.has(mod_id) and
# Check if the entry has a zip_path key
mod_list_entry.has("zip_path") and
# Check if the entry has a zip_path
not mod_list_entry.zip_path.is_empty() and
# Check if the zip file for the mod doesn't exist
not _ModLoaderFile.file_exists(mod_list_entry.zip_path)
):
# If the mod directory doesn't exist,
# the mod is no longer installed and can be removed from the mod list
ModLoaderLog.debug(
"Mod \"%s\" has been deleted from all user profiles as the corresponding zip file no longer exists at path \"%s\"."
% [mod_id, mod_list_entry.zip_path],
LOG_NAME,
true
)
updated_mod_list.erase(mod_id)
continue
updated_mod_list[mod_id] = mod_list_entry
return updated_mod_list
# Generates a dictionary with data to be stored for each mod.
static func _generate_mod_list() -> Dictionary:
var mod_list := {}
# Create a mod_list with the currently loaded mods
for mod_id in ModLoaderStore.mod_data.keys():
mod_list[mod_id] = _generate_mod_list_entry(mod_id, true)
# Add the deactivated mods to the list
for mod_id in ModLoaderStore.ml_options.disabled_mods:
mod_list[mod_id] = _generate_mod_list_entry(mod_id, false)
return mod_list
# Generates a mod list entry dictionary with the given mod ID and active status.
# If the mod has a config schema, sets the 'current_config' key to the current_config stored in the Mods ModData.
static func _generate_mod_list_entry(mod_id: String, is_active: bool) -> Dictionary:
var mod_list_entry := {}
# Set the mods active state
mod_list_entry.is_active = is_active
# Set the mods zip path if available
if ModLoaderStore.mod_data.has(mod_id):
mod_list_entry.zip_path = ModLoaderStore.mod_data[mod_id].zip_path
# Set the current_config if the mod has a config schema and is active
if is_active and not ModLoaderConfig.get_config_schema(mod_id).is_empty():
var current_config: ModConfig = ModLoaderStore.mod_data[mod_id].current_config
if current_config and current_config.is_valid:
# Set to the current_config name if valid
mod_list_entry.current_config = current_config.name
else:
# If not valid revert to the default config
mod_list_entry.current_config = ModLoaderConfig.DEFAULT_CONFIG_NAME
return mod_list_entry
# Handles the activation or deactivation of a mod in a user profile.
static func _set_mod_state(mod_id: String, profile_name: String, should_activate: bool, force := false) -> bool:
# Verify whether the mod_id is present in the profile's mod_list.
if not _is_mod_id_in_mod_list(mod_id, profile_name):
return false
# Handle mod state
# Set state in the ModData
var was_toggled: bool = ModLoaderStore.mod_data[mod_id].set_mod_state(should_activate, force)
if not was_toggled:
return false
# Set state for user profile
ModLoaderStore.user_profiles[profile_name].mod_list[mod_id].is_active = should_activate
# Save profiles to the user profiles JSON file
var is_save_success := _save()
if is_save_success:
ModLoaderLog.debug("Mod activation state changed: mod_id=%s should_activate=%s profile_name=%s" % [mod_id, should_activate, profile_name], LOG_NAME)
return is_save_success
# Checks whether a given mod_id is present in the mod_list of the specified user profile.
# Returns True if the mod_id is present, False otherwise.
static func _is_mod_id_in_mod_list(mod_id: String, profile_name: String) -> bool:
# Get the user profile
var user_profile := get_profile(profile_name)
if not user_profile:
# Return false if there is an error getting the user profile
return false
# Return false if the mod_id is not in the profile's mod_list
if not user_profile.mod_list.has(mod_id):
ModLoaderLog.error("Mod id \"%s\" not found in the \"mod_list\" of user profile \"%s\"." % [mod_id, profile_name], LOG_NAME)
return false
# Return true if the mod_id is in the profile's mod_list
return true
# Creates a new Profile with the given name and mod list.
# Returns the newly created Profile object.
static func _create_new_profile(profile_name: String, mod_list: Dictionary) -> ModUserProfile:
var new_profile := ModUserProfile.new()
# If no name is provided, log an error and return null
if profile_name == "":
ModLoaderLog.error("Please provide a name for the new profile", LOG_NAME)
return null
# Set the profile name
new_profile.name = profile_name
# If no mods are specified in the mod_list, log a warning and return the new profile
if mod_list.keys().size() == 0:
ModLoaderLog.info("No mod_ids inside \"mod_list\" for user profile \"%s\" " % profile_name, LOG_NAME)
return new_profile
# Set the mod_list
new_profile.mod_list = _update_mod_list(mod_list)
return new_profile
# Loads user profiles from the JSON file and adds them to ModLoaderStore.
static func _load() -> bool:
# Load JSON data from the user profiles file
var data := _ModLoaderFile.get_json_as_dict(FILE_PATH_USER_PROFILES)
# If there is no data, log an error and return
if data.is_empty():
ModLoaderLog.error("No profile file found at \"%s\"" % FILE_PATH_USER_PROFILES, LOG_NAME)
return false
# Loop through each profile in the data and add them to ModLoaderStore
for profile_name in data.profiles.keys():
# Get the profile data from the JSON object
var profile_data: Dictionary = data.profiles[profile_name]
# Create a new profile object and add it to ModLoaderStore.user_profiles
var new_profile := _create_new_profile(profile_name, profile_data.mod_list)
ModLoaderStore.user_profiles[profile_name] = new_profile
# Set the current user profile to the one specified in the data
ModLoaderStore.current_user_profile = ModLoaderStore.user_profiles[data.current_profile]
return true
# Saves the user profiles in the ModLoaderStore to the user profiles JSON file.
static func _save() -> bool:
# Initialize a dictionary to hold the serialized user profiles data
var save_dict := {
"current_profile": "",
"profiles": {}
}
# Set the current profile name in the save_dict
save_dict.current_profile = ModLoaderStore.current_user_profile.name
# Serialize the mod_list data for each user profile and add it to the save_dict
for profile_name in ModLoaderStore.user_profiles.keys():
var profile: ModUserProfile = ModLoaderStore.user_profiles[profile_name]
# Init the profile dict
save_dict.profiles[profile.name] = {}
# Init the mod_list dict
save_dict.profiles[profile.name].mod_list = profile.mod_list
# Save the serialized user profiles data to the user profiles JSON file
return _ModLoaderFile.save_dictionary_to_json_file(save_dict, FILE_PATH_USER_PROFILES)

View File

@@ -0,0 +1 @@
uid://c0u28df0ffhan

View File

@@ -0,0 +1,85 @@
class_name _ModLoaderCache
extends RefCounted
# This Class provides methods for caching data.
const CACHE_FILE_PATH = "user://mod_loader_cache.json"
const LOG_NAME = "ModLoader:Cache"
# ModLoaderStore is passed as parameter so the cache data can be loaded on ModLoaderStore._init()
static func init_cache(_ModLoaderStore) -> void:
if not _ModLoaderFile.file_exists(CACHE_FILE_PATH):
_init_cache_file()
return
_load_file(_ModLoaderStore)
# Adds data to the cache
static func add_data(key: String, data: Dictionary) -> Dictionary:
if ModLoaderStore.cache.has(key):
ModLoaderLog.error("key: \"%s\" already exists in \"ModLoaderStore.cache\"" % key, LOG_NAME)
return {}
ModLoaderStore.cache[key] = data
return ModLoaderStore.cache[key]
# Get data from a specific key
static func get_data(key: String) -> Dictionary:
if not ModLoaderStore.cache.has(key):
ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
return {}
return ModLoaderStore.cache[key]
# Get the entire cache dictionary
static func get_cache() -> Dictionary:
return ModLoaderStore.cache
static func has_key(key: String) -> bool:
return ModLoaderStore.cache.has(key)
# Updates or adds data to the cache
static func update_data(key: String, data: Dictionary) -> Dictionary:
# If the key exists
if has_key(key):
# Update the data
ModLoaderStore.cache[key].merge(data, true)
else:
ModLoaderLog.info("key: \"%s\" not found in \"ModLoaderStore.cache\" added as new data instead." % key, LOG_NAME, true)
# Else add new data
add_data(key, data)
return ModLoaderStore.cache[key]
# Remove data from the cache
static func remove_data(key: String) -> void:
if not ModLoaderStore.cache.has(key):
ModLoaderLog.error("key: \"%s\" not found in \"ModLoaderStore.cache\"" % key, LOG_NAME)
return
ModLoaderStore.cache.erase(key)
# Save the cache to the cache file
static func save_to_file() -> void:
_ModLoaderFile.save_dictionary_to_json_file(ModLoaderStore.cache, CACHE_FILE_PATH)
# Load the cache file data and store it in ModLoaderStore
# ModLoaderStore is passed as parameter so the cache data can be loaded on ModLoaderStore._init()
static func _load_file(_ModLoaderStore = ModLoaderStore) -> void:
_ModLoaderStore.cache = _ModLoaderFile.get_json_as_dict(CACHE_FILE_PATH)
# Create an empty cache file
static func _init_cache_file() -> void:
_ModLoaderFile.save_dictionary_to_json_file({}, CACHE_FILE_PATH)

View File

@@ -0,0 +1 @@
uid://b73enisoxe0uq

View File

@@ -0,0 +1,85 @@
class_name _ModLoaderCLI
extends RefCounted
# This Class provides util functions for working with cli arguments.
# Currently all of the included functions are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:CLI"
# Check if the provided command line argument was present when launching the game
static func is_running_with_command_line_arg(argument: String) -> bool:
for arg in OS.get_cmdline_args():
if argument == arg.split("=")[0]:
return true
return false
# Get the command line argument value if present when launching the game
static func get_cmd_line_arg_value(argument: String) -> String:
var args := _get_fixed_cmdline_args()
for arg_index in args.size():
var arg := args[arg_index] as String
var key := arg.split("=")[0]
if key == argument:
# format: `--arg=value` or `--arg="value"`
if "=" in arg:
var value := arg.trim_prefix(argument + "=")
value = value.trim_prefix('"').trim_suffix('"')
value = value.trim_prefix("'").trim_suffix("'")
return value
# format: `--arg value` or `--arg "value"`
elif arg_index +1 < args.size() and not args[arg_index +1].begins_with("--"):
return args[arg_index + 1]
return ""
static func _get_fixed_cmdline_args() -> PackedStringArray:
return fix_godot_cmdline_args_string_space_splitting(OS.get_cmdline_args())
# Reverses a bug in Godot, which splits input strings at spaces even if they are quoted
# e.g. `--arg="some value" --arg-two 'more value'` becomes `[ --arg="some, value", --arg-two, 'more, value' ]`
static func fix_godot_cmdline_args_string_space_splitting(args: PackedStringArray) -> PackedStringArray:
if not OS.has_feature("editor"): # only happens in editor builds
return args
if OS.has_feature("windows"): # windows is unaffected
return args
var fixed_args := PackedStringArray([])
var fixed_arg := ""
# if we encounter an argument that contains `=` followed by a quote,
# or an argument that starts with a quote, take all following args and
# concatenate them into one, until we find the closing quote
for arg in args:
var arg_string := arg as String
if '="' in arg_string or '="' in fixed_arg or \
arg_string.begins_with('"') or fixed_arg.begins_with('"'):
if not fixed_arg == "":
fixed_arg += " "
fixed_arg += arg_string
if arg_string.ends_with('"'):
fixed_args.append(fixed_arg.trim_prefix(" "))
fixed_arg = ""
continue
# same thing for single quotes
elif "='" in arg_string or "='" in fixed_arg \
or arg_string.begins_with("'") or fixed_arg.begins_with("'"):
if not fixed_arg == "":
fixed_arg += " "
fixed_arg += arg_string
if arg_string.ends_with("'"):
fixed_args.append(fixed_arg.trim_prefix(" "))
fixed_arg = ""
continue
else:
fixed_args.append(arg_string)
return fixed_args

View File

@@ -0,0 +1 @@
uid://c3rvk5ry6rqyq

View File

@@ -0,0 +1,131 @@
class_name _ModLoaderDependency
extends RefCounted
# This Class provides methods for working with dependencies.
# Currently all of the included methods are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:Dependency"
# Run dependency checks on a mod, checking any dependencies it lists in its
# mod_manifest (ie. its manifest.json file). If a mod depends on another mod that
# hasn't been loaded, the dependent mod won't be loaded, if it is a required dependency.
#
# Parameters:
# - mod: A ModData object representing the mod being checked.
# - dependency_chain: An array that stores the IDs of the mods that have already
# been checked to avoid circular dependencies.
# - is_required: A boolean indicating whether the mod is a required or optional
# dependency. Optional dependencies will not prevent the dependent mod from
# loading if they are missing.
#
# Returns: A boolean indicating whether a circular dependency was detected.
static func check_dependencies(mod: ModData, is_required := true, dependency_chain := []) -> bool:
var dependency_type := "required" if is_required else "optional"
# Get the dependency array based on the is_required flag
var dependencies := mod.manifest.dependencies if is_required else mod.manifest.optional_dependencies
# Get the ID of the mod being checked
var mod_id := mod.dir_name
ModLoaderLog.debug("Checking dependencies - mod_id: %s %s dependencies: %s" % [mod_id, dependency_type, dependencies], LOG_NAME)
# Check for circular dependency
if mod_id in dependency_chain:
ModLoaderLog.debug("%s dependency check - circular dependency detected for mod with ID %s." % [dependency_type.capitalize(), mod_id], LOG_NAME)
return true
# Add mod_id to dependency_chain to avoid circular dependencies
dependency_chain.append(mod_id)
# Loop through each dependency listed in the mod's manifest
for dependency_id in dependencies:
# Check if dependency is missing
if not ModLoaderStore.mod_data.has(dependency_id) or not ModLoaderStore.mod_data[dependency_id].is_loadable or not ModLoaderStore.mod_data[dependency_id].is_active:
# Skip to the next dependency if it's optional
if not is_required:
ModLoaderLog.info("Missing optional dependency - mod: -> %s dependency -> %s" % [mod_id, dependency_id], LOG_NAME)
continue
_handle_missing_dependency(mod_id, dependency_id)
# Flag the mod so it's not loaded later
mod.is_loadable = false
else:
var dependency: ModData = ModLoaderStore.mod_data[dependency_id]
# Increase the importance score of the dependency by 1
dependency.importance += 1
ModLoaderLog.debug("%s dependency -> %s importance -> %s" % [dependency_type.capitalize(), dependency_id, dependency.importance], LOG_NAME)
# Check if the dependency has any dependencies of its own
if dependency.manifest.dependencies.size() > 0:
if check_dependencies(dependency, is_required, dependency_chain):
return true
# Return false if all dependencies have been resolved
return false
# Run load before check on a mod, checking any load_before entries it lists in its
# mod_manifest (ie. its manifest.json file). Add the mod to the dependency of the
# mods inside the load_before array.
static func check_load_before(mod: ModData) -> void:
# Skip if no entries in load_before
if mod.manifest.load_before.size() == 0:
return
ModLoaderLog.debug("Load before - In mod %s detected." % mod.dir_name, LOG_NAME)
# For each mod id in load_before
for load_before_id in mod.manifest.load_before:
# Check if the load_before mod exists
if not ModLoaderStore.mod_data.has(load_before_id):
ModLoaderLog.debug("Load before - Skipping %s because it's missing" % load_before_id, LOG_NAME)
continue
var load_before_mod_dependencies := ModLoaderStore.mod_data[load_before_id].manifest.dependencies as PackedStringArray
# Check if it's already a dependency
if mod.dir_name in load_before_mod_dependencies:
ModLoaderLog.debug("Load before - Skipping because it's already a dependency for %s" % load_before_id, LOG_NAME)
continue
# Add the mod to the dependency array
load_before_mod_dependencies.append(mod.dir_name)
ModLoaderStore.mod_data[load_before_id].manifest.dependencies = load_before_mod_dependencies
ModLoaderLog.debug("Load before - Added %s as dependency for %s" % [mod.dir_name, load_before_id], LOG_NAME)
# Get the load order of mods, using a custom sorter
static func get_load_order(mod_data_array: Array) -> Array:
# Add loadable mods to the mod load order array
for mod in mod_data_array:
mod = mod as ModData
if mod.is_loadable:
ModLoaderStore.mod_load_order.append(mod)
# Sort mods by the importance value
ModLoaderStore.mod_load_order.sort_custom(Callable(CompareImportance, "_compare_importance"))
return ModLoaderStore.mod_load_order
# Handles a missing dependency for a given mod ID. Logs an error message indicating the missing dependency and adds
# the dependency ID to the mod_missing_dependencies dictionary for the specified mod.
static func _handle_missing_dependency(mod_id: String, dependency_id: String) -> void:
ModLoaderLog.error("Missing dependency - mod: -> %s dependency -> %s" % [mod_id, dependency_id], LOG_NAME)
# if mod is not present in the missing dependencies array
if not ModLoaderStore.mod_missing_dependencies.has(mod_id):
# add it
ModLoaderStore.mod_missing_dependencies[mod_id] = []
ModLoaderStore.mod_missing_dependencies[mod_id].append(dependency_id)
# Inner class so the sort function can be called by get_load_order()
class CompareImportance:
# Custom sorter that orders mods by important
static func _compare_importance(a: ModData, b: ModData) -> bool:
if a.importance > b.importance:
return true # a -> b
else:
return false # b -> a

View File

@@ -0,0 +1 @@
uid://bnmjbsvid8sxk

View File

@@ -0,0 +1,241 @@
class_name _ModLoaderFile
extends RefCounted
# This Class provides util functions for working with files.
# Currently all of the included functions are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:File"
# Get Data
# =============================================================================
# Parses JSON from a given file path and returns a [Dictionary].
# Returns an empty [Dictionary] if no file exists (check with size() < 1)
static func get_json_as_dict(path: String) -> Dictionary:
if not file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
var error = file.get_open_error()
if file == null:
ModLoaderLog.error("Error opening file. Code: %s" % error, LOG_NAME)
var content := file.get_as_text()
return _get_json_string_as_dict(content)
# Parses JSON from a given [String] and returns a [Dictionary].
# Returns an empty [Dictionary] on error (check with size() < 1)
static func _get_json_string_as_dict(string: String) -> Dictionary:
if string == "":
return {}
var test_json_conv = JSON.new()
var error = test_json_conv.parse(string)
if not error == OK:
ModLoaderLog.error("Error parsing JSON", LOG_NAME)
return {}
if not test_json_conv.data is Dictionary:
ModLoaderLog.error("JSON is not a dictionary", LOG_NAME)
return {}
return test_json_conv.data
# Opens the path and reports all the errors that can happen
static func open_dir(folder_path: String) -> DirAccess:
var mod_dir := DirAccess.open(folder_path)
if mod_dir == null:
ModLoaderLog.error("Can't open mod folder %s" % [folder_path], LOG_NAME)
return null
var mod_dir_open_error := mod_dir.get_open_error()
if not mod_dir_open_error == OK:
ModLoaderLog.info(
"Can't open mod folder %s (Error: %s, %s)" %
[folder_path, mod_dir_open_error, error_string(mod_dir_open_error)],
LOG_NAME
)
return null
var mod_dir_listdir_error := mod_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
if not mod_dir_listdir_error == OK:
ModLoaderLog.error(
"Can't read mod folder %s (Error: %s, %s)" %
[folder_path, mod_dir_listdir_error, error_string(mod_dir_listdir_error)],
LOG_NAME
)
return null
return mod_dir
static func get_json_as_dict_from_zip(zip_path: String, file_path: String, is_full_path := false) -> Dictionary:
if not file_exists(zip_path):
ModLoaderLog.error("Zip was not found at %s" % [zip_path], LOG_NAME)
return {}
var reader := ZIPReader.new()
var zip_open_error := reader.open(zip_path)
if not zip_open_error == OK:
ModLoaderLog.error(
"Error opening zip. (Error: %s, %s)" %
[zip_open_error, error_string(zip_open_error)],
LOG_NAME
)
var full_path := ""
if is_full_path:
full_path = file_path
if not reader.file_exists(full_path):
ModLoaderLog.error("File was not found in zip at path %s" % [file_path], LOG_NAME)
return {}
else:
# Go through all files and find the file
# Since we don't know which mod folder will be in the zip to get the exact full path
# (zip naming is not required to be the name as folder name)
for path in reader.get_files():
if Array(path.rsplit("/", false, 1)).back() == file_path:
full_path = path
if not full_path:
ModLoaderLog.error("File was not found in zip at path %s" % [file_path], LOG_NAME)
return {}
var content := reader.read_file(full_path).get_string_from_utf8()
return _get_json_string_as_dict(content)
# Save Data
# =============================================================================
# Saves a dictionary to a file, as a JSON string
static func _save_string_to_file(save_string: String, filepath: String) -> bool:
# Create directory if it doesn't exist yet
var file_directory := filepath.get_base_dir()
var dir := DirAccess.open(file_directory)
_code_note(str(
"View error codes here:",
"https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#enum-globalscope-error"
))
if not dir:
var makedir_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(file_directory))
if not makedir_error == OK:
ModLoaderLog.fatal("Encountered an error (%s) when attempting to create a directory, with the path: %s" % [makedir_error, file_directory], LOG_NAME)
return false
# Save data to the file
var file := FileAccess.open(filepath, FileAccess.WRITE)
if not file:
ModLoaderLog.fatal("Encountered an error (%s) when attempting to write to a file, with the path: %s" % [FileAccess.get_open_error(), filepath], LOG_NAME)
return false
file.store_string(save_string)
file.close()
return true
# Saves a dictionary to a file, as a JSON string
static func save_dictionary_to_json_file(data: Dictionary, filepath: String) -> bool:
var json_string := JSON.stringify(data, "\t")
return _save_string_to_file(json_string, filepath)
# Remove Data
# =============================================================================
# Removes a file from the given path
static func remove_file(file_path: String) -> bool:
var dir := DirAccess.open(file_path)
if not dir.file_exists(file_path):
ModLoaderLog.error("No file found at \"%s\"" % file_path, LOG_NAME)
return false
var error := dir.remove(file_path)
if error:
ModLoaderLog.error(
"Encountered an error (%s) when attempting to remove the file, with the path: %s"
% [error, file_path],
LOG_NAME
)
return false
return true
# Checks
# =============================================================================
static func file_exists(path: String, zip_path: String = "") -> bool:
if not zip_path.is_empty():
return file_exists_in_zip(zip_path, path)
var exists := FileAccess.file_exists(path)
# If the file is not found, check if it has been remapped because it is a Resource.
if not exists:
exists = ResourceLoader.exists(path)
return exists
static func dir_exists(path: String) -> bool:
return DirAccess.dir_exists_absolute(path)
static func file_exists_in_zip(zip_path: String, path: String) -> bool:
var reader := zip_reader_open(zip_path)
if not reader:
return false
if _ModLoaderGodot.is_version_below(_ModLoaderGodot.ENGINE_VERSION_HEX_4_2_0):
return reader.get_files().has(path.trim_prefix("res://"))
else:
return reader.file_exists(path.trim_prefix("res://"))
static func get_mod_dir_name_in_zip(zip_path: String) -> String:
var reader := _ModLoaderFile.zip_reader_open(zip_path)
if not reader:
return ""
var file_paths := reader.get_files()
for file_path in file_paths:
# We asume tat the mod_main.gd is at the root of the mod dir
if file_path.ends_with("mod_main.gd") and file_path.split("/").size() == 3:
return file_path.split("/")[-2]
return ""
static func zip_reader_open(zip_path) -> ZIPReader:
var reader := ZIPReader.new()
var err := reader.open(zip_path)
if err != OK:
ModLoaderLog.error("Could not open zip with error: %s" % error_string(err), LOG_NAME)
return
return reader
static func load_manifest_file(path: String) -> Dictionary:
ModLoaderLog.debug("Loading mod_manifest from -> %s" % path, LOG_NAME)
if _ModLoaderPath.is_zip(path):
return get_json_as_dict_from_zip(path, ModData.MANIFEST)
return get_json_as_dict(path.path_join(ModData.MANIFEST))
# This is a dummy func. It is exclusively used to show notes in the code that
# stay visible after decompiling a PCK, as is primarily intended to assist new
# modders in understanding and troubleshooting issues.
static func _code_note(_msg:String):
pass

View File

@@ -0,0 +1 @@
uid://d34sgvhw73mtb

View File

@@ -0,0 +1,116 @@
@tool
class_name _ModLoaderGodot
extends Object
# This Class provides methods for interacting with Godot.
# Currently all of the included methods are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:Godot"
const AUTOLOAD_CONFIG_HELP_MSG := "To configure your autoloads, go to Project > Project Settings > Autoload."
const ENGINE_VERSION_HEX_4_2_2 := 0x040202
const ENGINE_VERSION_HEX_4_2_0 := 0x040200
static var engine_version_hex: int = Engine.get_version_info().hex
# Check autoload positions:
# Ensure 1st autoload is `ModLoaderStore`, and 2nd is `ModLoader`.
static func check_autoload_positions() -> void:
var override_cfg_path := _ModLoaderPath.get_override_path()
var is_override_cfg_setup := _ModLoaderFile.file_exists(override_cfg_path)
# If the override file exists we assume the ModLoader was setup with the --setup-create-override-cfg cli arg
# In that case the ModLoader will be the last entry in the autoload array
if is_override_cfg_setup:
ModLoaderLog.info("override.cfg setup detected, ModLoader will be the last autoload loaded.", LOG_NAME)
return
# If there are Autoloads that need to be before the ModLoader
# "allow_modloader_autoloads_anywhere" in the ModLoader Options can be enabled.
# With that only the correct order of, ModLoaderStore first and ModLoader second, is checked.
if ModLoaderStore.ml_options.allow_modloader_autoloads_anywhere:
is_autoload_before("ModLoaderStore", "ModLoader", true)
else:
var _pos_ml_store := check_autoload_position("ModLoaderStore", 0, true)
var _pos_ml_core := check_autoload_position("ModLoader", 1, true)
# Check if autoload_name_before is before autoload_name_after
# Returns a bool if the position does not match.
# Optionally triggers a fatal error
static func is_autoload_before(autoload_name_before: String, autoload_name_after: String, trigger_error := false) -> bool:
var autoload_name_before_index := get_autoload_index(autoload_name_before)
var autoload_name_after_index := get_autoload_index(autoload_name_after)
# Check if the Store is before the ModLoader
if not autoload_name_before_index < autoload_name_after_index:
var error_msg := (
"Expected %s ( position: %s ) to be loaded before %s ( position: %s ). "
% [autoload_name_before, autoload_name_before_index, autoload_name_after, autoload_name_after_index]
)
var help_msg := AUTOLOAD_CONFIG_HELP_MSG if OS.has_feature("editor") else ""
if trigger_error:
var final_message = error_msg + help_msg
push_error(final_message)
ModLoaderLog._write_to_log_file(final_message)
ModLoaderLog._write_to_log_file(JSON.stringify(get_stack(), " "))
assert(false, final_message)
return false
return true
# Check the index position of the provided autoload (0 = 1st, 1 = 2nd, etc).
# Returns a bool if the position does not match.
# Optionally triggers a fatal error
static func check_autoload_position(autoload_name: String, position_index: int, trigger_error := false) -> bool:
var autoload_array := get_autoload_array()
var autoload_index := autoload_array.find(autoload_name)
var position_matches := autoload_index == position_index
if not position_matches and trigger_error:
var error_msg := (
"Expected %s to be the autoload in position %s, but this is currently %s. "
% [autoload_name, str(position_index + 1), autoload_array[position_index]]
)
var help_msg := AUTOLOAD_CONFIG_HELP_MSG if OS.has_feature("editor") else ""
var final_message = error_msg + help_msg
push_error(final_message)
ModLoaderLog._write_to_log_file(final_message)
ModLoaderLog._write_to_log_file(JSON.stringify(get_stack(), " "))
assert(false, final_message)
return position_matches
# Get an array of all autoloads -> ["autoload/AutoloadName", ...]
static func get_autoload_array() -> Array:
var autoloads := []
# Get all autoload settings
for prop in ProjectSettings.get_property_list():
var name: String = prop.name
if name.begins_with("autoload/"):
autoloads.append(name.trim_prefix("autoload/"))
return autoloads
# Get the index of a specific autoload
static func get_autoload_index(autoload_name: String) -> int:
var autoloads := get_autoload_array()
var autoload_index := autoloads.find(autoload_name)
return autoload_index
static func is_version_below(version_hex: int) -> bool:
return engine_version_hex < version_hex
static func is_version_above(version_hex: int) -> bool:
return engine_version_hex > version_hex

View File

@@ -0,0 +1 @@
uid://dc86ulvsd1da7

View File

@@ -0,0 +1,60 @@
@tool
class_name _ModLoaderHooks
extends Object
# This Class provides utility functions for working with Mod Hooks.
# Currently all of the included functions are internal and should only be used by the mod loader itself.
# Functions with external use are exposed through the ModLoaderMod class.
const LOG_NAME := "ModLoader:Hooks"
static var any_mod_hooked := false
## 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:
any_mod_hooked = true
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 "%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] = [method_name]
elif not ModLoaderStore.hooked_script_paths[script_path].has(method_name):
ModLoaderStore.hooked_script_paths[script_path].append(method_name)
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)
var chain := ModLoaderHookChain.new(vanilla_method.get_object(), [vanilla_method] + hooks)
return chain.execute_next(args)
static func call_hooks_async(vanilla_method: Callable, args: Array, hook_hash: int) -> Variant:
var hooks: Array = ModLoaderStore.modding_hooks.get(hook_hash, [])
if hooks.is_empty():
return await vanilla_method.callv(args)
var chain := ModLoaderHookChain.new(vanilla_method.get_object(), [vanilla_method] + hooks)
return await chain.execute_next_async(args)
static func get_hook_hash(path: String, method: String) -> int:
return hash(path + method)
static func on_new_hooks_created() -> void:
if ModLoaderStore.ml_options.disable_restart:
ModLoaderLog.debug("Mod Loader handled restart is disabled.", LOG_NAME)
return
ModLoaderLog.debug("Instancing restart notification scene from path: %s" % [ModLoaderStore.ml_options.restart_notification_scene_path], LOG_NAME)
var restart_notification_scene = load(ModLoaderStore.ml_options.restart_notification_scene_path).instantiate()
ModLoader.add_child(restart_notification_scene)

View File

@@ -0,0 +1 @@
uid://yrog1crr7kxp

View File

@@ -0,0 +1,74 @@
class_name _ModLoaderModHookPacker
extends RefCounted
# This class is used to generate mod hooks on demand and pack them into a zip file.
# Currently all of the included functions are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:ModHookPacker"
static func start() -> void:
ModLoaderLog.info("Generating mod hooks .zip", LOG_NAME)
var hook_pre_processor = _ModLoaderModHookPreProcessor.new()
hook_pre_processor.process_begin()
var mod_hook_pack_path := _ModLoaderPath.get_path_to_hook_pack()
# Create mod hook pack path if necessary
if not DirAccess.dir_exists_absolute(mod_hook_pack_path.get_base_dir()):
var error := DirAccess.make_dir_recursive_absolute(mod_hook_pack_path.get_base_dir())
if not error == OK:
ModLoaderLog.error("Error creating the mod hook directory at %s" % mod_hook_pack_path, LOG_NAME)
return
ModLoaderLog.debug("Created dir at: %s" % mod_hook_pack_path, LOG_NAME)
# Create mod hook zip
var zip_writer := ZIPPacker.new()
var error: Error
if not FileAccess.file_exists(mod_hook_pack_path):
# Clear cache if the hook pack does not exist
_ModLoaderCache.remove_data("hooks")
error = zip_writer.open(mod_hook_pack_path)
else:
# If there is a pack already, append to it
error = zip_writer.open(mod_hook_pack_path, ZIPPacker.APPEND_ADDINZIP)
if not error == OK:
ModLoaderLog.error("Error (%s) writing to hooks zip, consider deleting this file: %s" % [error, mod_hook_pack_path], LOG_NAME)
return
ModLoaderLog.debug("Scripts requiring hooks: %s" % [ModLoaderStore.hooked_script_paths], LOG_NAME)
var cache := _ModLoaderCache.get_data("hooks")
var cached_script_paths: Dictionary = {} if cache.is_empty() or not cache.has("hooked_script_paths") else cache.hooked_script_paths
if cached_script_paths == ModLoaderStore.hooked_script_paths:
ModLoaderLog.info("Scripts are already processed according to cache, skipping process.", LOG_NAME)
zip_writer.close()
return
var new_hooks_created := false
# Get all scripts that need processing
for path in ModLoaderStore.hooked_script_paths.keys():
var method_mask: Array[String] = []
method_mask.assign(ModLoaderStore.hooked_script_paths[path])
var processed_source_code := hook_pre_processor.process_script_verbose(path, false, method_mask)
# Skip writing to the zip if no new hooks were created for this script
if not hook_pre_processor.script_paths_hooked.has(path):
ModLoaderLog.debug("No new hooks were created in \"%s\", skipping writing to hook pack." % path, LOG_NAME)
continue
zip_writer.start_file(path.trim_prefix("res://"))
zip_writer.write_file(processed_source_code.to_utf8_buffer())
zip_writer.close_file()
ModLoaderLog.debug("Hooks created for script: %s" % path, LOG_NAME)
new_hooks_created = true
if new_hooks_created:
_ModLoaderCache.update_data("hooks", {"hooked_script_paths": ModLoaderStore.hooked_script_paths})
_ModLoaderCache.save_to_file()
ModLoader.new_hooks_created.emit()
zip_writer.close()

View File

@@ -0,0 +1 @@
uid://dnwq8741pln26

View File

@@ -0,0 +1,649 @@
@tool
class_name _ModLoaderModHookPreProcessor
extends RefCounted
# This class is used to process the source code from a script at a given path.
# Currently all of the included functions are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:ModHookPreProcessor"
const REQUIRE_EXPLICIT_ADDITION := false
const METHOD_PREFIX := "vanilla_"
const HASH_COLLISION_ERROR := \
"MODDING HOOKS ERROR: Hash collision between %s and %s. The collision can be resolved by renaming one of the methods or changing their script's path."
const MOD_LOADER_HOOKS_START_STRING := \
"\n# ModLoader Hooks - The following code has been automatically added by the Godot Mod Loader."
## \\bfunc\\b\\s+ -> Match the word 'func' and one or more whitespace characters
## \\b%s\\b -> the function name
## (?:.*\\n*)*?\\s*\\( -> Match any character between zero and unlimited times, but be lazy
## and only do this until a '(' is found.
const REGEX_MATCH_FUNC_WITH_WHITESPACE := "\\bfunc\\b\\s+\\b%s\\b(?:.*\\n*)*?\\s*\\("
## finds function names used as setters and getters (excluding inline definitions)
## group 2 and 4 contain the setter/getter names
var regex_getter_setter := RegEx.create_from_string("(.*?[sg]et\\s*=\\s*)(\\w+)(\\g<1>)?(\\g<2>)?")
## finds every instance where super() is called
## returns only the super word, excluding the (, as match to make substitution easier
var regex_super_call := RegEx.create_from_string("\\bsuper(?=\\s*\\()")
## Matches the indented function body.
## Needs to start from the : of a function declaration to work (.search() offset param)
## The body of a function is every line that is empty or starts with an indent or comment
var regex_func_body := RegEx.create_from_string("(?smn)\\N*(\\n^(([\\t #]+\\N*)|$))*")
## Just await between word boundaries
var regex_keyword_await := RegEx.create_from_string("\\bawait\\b")
## Just void between word boundaries
var regex_keyword_void := RegEx.create_from_string("\\bvoid\\b")
var hashmap := {}
var script_paths_hooked := {}
func process_begin() -> void:
hashmap.clear()
## Calls [method process_script] with additional logging
func process_script_verbose(path: String, enable_hook_check := false, method_mask: Array[String] = []) -> String:
var start_time := Time.get_ticks_msec()
ModLoaderLog.debug("Start processing script at path: %s" % path, LOG_NAME)
var processed := process_script(path, enable_hook_check, method_mask)
ModLoaderLog.debug("Finished processing script at path: %s in %s ms" % [path, Time.get_ticks_msec() - start_time], LOG_NAME)
return processed
## [param path]: File path to the script to be processed.[br]
## [param enable_hook_check]: Adds a check that _ModLoaderHooks.any_mod_hooked is [code]true[/code] to the processed method, reducing hash checks.[br]
## [param method_mask]: If provided, only methods in this [Array] will be processed.[br]
func process_script(path: String, enable_hook_check := false, method_mask: Array[String] = []) -> String:
var current_script := load(path) as GDScript
var source_code := current_script.source_code
var source_code_additions := ""
# We need to stop all vanilla methods from forming inheritance chains,
# since the generated methods will fulfill inheritance requirements
var class_prefix := str(hash(path))
var method_store: Array[String] = []
var getters_setters := collect_getters_and_setters(source_code)
var moddable_methods := current_script.get_script_method_list().filter(
is_func_moddable.bind(source_code, getters_setters)
)
var methods_hooked := {}
for method in moddable_methods:
if method.name in method_store:
continue
var full_prefix := "%s%s_" % [METHOD_PREFIX, class_prefix]
# Check if the method name starts with the prefix added by `edit_vanilla_method()`.
# This indicates that the method was previously processed, possibly by the export plugin.
# If so, store the method name (excluding the prefix) in `methods_hooked`.
if method.name.begins_with(full_prefix):
var method_name_vanilla: String = method.name.trim_prefix(full_prefix)
methods_hooked[method_name_vanilla] = true
continue
# This ensures we avoid creating a hook for the 'imposter' method, which
# is generated by `build_mod_hook_string()` and has the vanilla method name.
if methods_hooked.has(method.name):
continue
# If a mask is provided, only methods with their name in the mask will be converted.
# Can't be filtered before the loop since it removes prefixed methods required by the previous check.
if not method_mask.is_empty():
if not method.name in method_mask:
continue
var type_string := get_return_type_string(method.return)
var is_static := true if method.flags == METHOD_FLAG_STATIC + METHOD_FLAG_NORMAL else false
var func_def: RegExMatch = match_func_with_whitespace(method.name, source_code)
if not func_def: # Could not regex match a function with that name
continue # Means invalid Script, should never happen
# Processing does not cover methods in subclasses yet.
# If a function with the same name was found in a subclass,
# try again until we find the top level one
var max_loop := 1000
while not is_top_level_func(source_code, func_def.get_start(), is_static): # indent before "func"
func_def = match_func_with_whitespace(method.name, source_code, func_def.get_end())
if not func_def or max_loop <= 0: # Couldn't match any func like before
break # Means invalid Script, unless it's a child script.
# In such cases, the method name might be listed in the script_method_list
# but absent in the actual source_code.
max_loop -= 1
if not func_def: # If no valid function definition is found after processing.
continue # Skip to the next iteration.
# Shift the func_def_end index back by one to start on the opening parentheses.
# Because the match_func_with_whitespace().get_end() is the index after the opening parentheses.
var closing_paren_index := get_closing_paren_index(func_def.get_end() - 1, source_code)
var func_body_start_index := get_func_body_start_index(closing_paren_index, source_code)
if func_body_start_index == -1: # The function is malformed, opening ( was not closed by )
continue # Means invalid Script, should never happen
var func_body := match_method_body(method.name, func_body_start_index, source_code)
if not func_body: # No indented lines found
continue # Means invalid Script, should never happen
var is_async := is_func_async(func_body.get_string())
var can_return := can_return(source_code, method.name, closing_paren_index, func_body_start_index)
var method_arg_string_with_defaults_and_types := get_function_parameters(method.name, source_code, is_static)
var method_arg_string_names_only := get_function_arg_name_string(method.args)
var hook_id := _ModLoaderHooks.get_hook_hash(path, method.name)
var hook_id_data := [path, method.name, true]
if hashmap.has(hook_id):
push_error(HASH_COLLISION_ERROR%[hashmap[hook_id], hook_id_data])
hashmap[hook_id] = hook_id_data
var mod_loader_hook_string := build_mod_hook_string(
method.name,
method_arg_string_names_only,
method_arg_string_with_defaults_and_types,
type_string,
can_return,
is_static,
is_async,
hook_id,
full_prefix,
enable_hook_check
)
# Store the method name
# Not sure if there is a way to get only the local methods in a script,
# get_script_method_list() returns a full list,
# including the methods from the scripts it extends,
# which leads to multiple entries in the list if they are overridden by the child script.
method_store.push_back(method.name)
source_code = edit_vanilla_method(
method.name,
source_code,
func_def,
func_body,
full_prefix
)
source_code_additions += "\n%s" % mod_loader_hook_string
script_paths_hooked[path] = true
# If we have some additions to the code, append them at the end
if source_code_additions != "":
source_code = "%s\n%s\n%s" % [source_code, MOD_LOADER_HOOKS_START_STRING, source_code_additions]
return source_code
static func is_func_moddable(method: Dictionary, source_code: String, getters_setters := {}) -> bool:
if getters_setters.has(method.name):
return false
var method_first_line_start := _ModLoaderModHookPreProcessor.get_index_at_method_start(method.name, source_code)
if method_first_line_start == -1:
return false
if not _ModLoaderModHookPreProcessor.is_func_marked_moddable(method_first_line_start, source_code):
return false
return true
func is_func_async(func_body_text: String) -> bool:
if not func_body_text.contains("await"):
return false
var lines := func_body_text.split("\n")
var in_multiline_string := false
var current_multiline_delimiter := ""
for _line in lines:
var line: String = _line
var char_index := 0
while char_index < line.length():
if in_multiline_string:
# Check if we are exiting the multiline string
if line.substr(char_index).begins_with(current_multiline_delimiter):
in_multiline_string = false
char_index += 3
else:
char_index += 1
continue
# Comments: Skip the rest of the line
if line.substr(char_index).begins_with("#"):
break
# Check for multiline string start
if line.substr(char_index).begins_with('"""') or line.substr(char_index).begins_with("'''"):
in_multiline_string = true
current_multiline_delimiter = line.substr(char_index, 3)
char_index += 3
continue
# Check for single-quoted strings
if line[char_index] == '"' or line[char_index] == "'":
var delimiter = line[char_index]
char_index += 1
while char_index < line.length() and line[char_index] != delimiter:
# Skip escaped quotes
if line[char_index] == "\\":
char_index += 1
char_index += 1
char_index += 1 # Skip the closing quote
continue
# Check for the "await" keyword
if not line.substr(char_index).begins_with("await"):
char_index += 1
continue
# Ensure "await" is a standalone word
var start := char_index -1 if char_index > 0 else 0
if regex_keyword_await.search(line.substr(start)):
return true # Just return here, we don't need every occurence
# i += 5 # Normal parser: Skip the keyword
else:
char_index += 1
return false
static func get_function_arg_name_string(args: Array) -> String:
var arg_string := ""
for x in args.size():
if x == args.size() -1:
arg_string += args[x].name
else:
arg_string += "%s, " % args[x].name
return arg_string
static func get_function_parameters(method_name: String, text: String, is_static: bool, offset := 0) -> String:
var result := match_func_with_whitespace(method_name, text, offset)
if result == null:
return ""
# Find the index of the opening parenthesis
var opening_paren_index := result.get_end() - 1
if opening_paren_index == -1:
return ""
if not is_top_level_func(text, result.get_start(), is_static):
return get_function_parameters(method_name, text, is_static, result.get_end())
# Shift the func_def_end index back by one to start on the opening parentheses.
# Because the match_func_with_whitespace().get_end() is the index after the opening parentheses.
var closing_paren_index := get_closing_paren_index(opening_paren_index - 1, text)
if closing_paren_index == -1:
return ""
# Extract the substring between the parentheses
var param_string := text.substr(opening_paren_index + 1, closing_paren_index - opening_paren_index - 1)
# Clean whitespace characters (spaces, newlines, tabs)
param_string = param_string.strip_edges()\
.replace(" ", "")\
.replace("\t", "")\
.replace(",", ", ")\
.replace(":", ": ")
return param_string
static func get_closing_paren_index(opening_paren_index: int, text: String) -> int:
# Use a stack counter to match parentheses
var stack := 0
var closing_paren_index := opening_paren_index
while closing_paren_index < text.length():
var char := text[closing_paren_index]
if char == '(':
stack += 1
elif char == ')':
stack -= 1
if stack == 0:
break
closing_paren_index += 1
# If the stack is not empty, that means there's no matching closing parenthesis
if stack != 0:
return -1
return closing_paren_index
func edit_vanilla_method(
method_name: String,
text: String,
func_def: RegExMatch,
func_body: RegExMatch,
prefix := METHOD_PREFIX,
) -> String:
text = fix_method_super(method_name, func_body, text)
text = text.erase(func_def.get_start(), func_def.get_end() - func_def.get_start())
text = text.insert(func_def.get_start(), "func %s%s(" % [prefix, method_name])
return text
func fix_method_super(method_name: String, func_body: RegExMatch, text: String) -> String:
if _ModLoaderGodot.is_version_below(_ModLoaderGodot.ENGINE_VERSION_HEX_4_2_2):
return fix_method_super_before_4_2_2(method_name, func_body, text)
return regex_super_call.sub(
text, "super.%s" % method_name,
true, func_body.get_start(), func_body.get_end()
)
# https://github.com/godotengine/godot/pull/86052
# Quote:
# When the end argument of RegEx.sub was used,
# it would truncate the Subject String before even doing the substitution.
func fix_method_super_before_4_2_2(method_name: String, func_body: RegExMatch, text: String) -> String:
var text_after_func_body_end := text.substr(func_body.get_end())
text = regex_super_call.sub(
text, "super.%s" % method_name,
true, func_body.get_start(), func_body.get_end()
)
text = text + text_after_func_body_end
return text
static func get_func_body_start_index(closing_paren_index: int, source_code: String) -> int:
if closing_paren_index == -1:
return -1
return source_code.find(":", closing_paren_index) + 1
func match_method_body(method_name: String, func_body_start_index: int, text: String) -> RegExMatch:
return regex_func_body.search(text, func_body_start_index)
static func match_func_with_whitespace(method_name: String, text: String, offset := 0) -> RegExMatch:
# Dynamically create the new regex for that specific name
var func_with_whitespace := RegEx.create_from_string(REGEX_MATCH_FUNC_WITH_WHITESPACE % method_name)
return func_with_whitespace.search(text, offset)
static func build_mod_hook_string(
method_name: String,
method_arg_string_names_only: String,
method_arg_string_with_defaults_and_types: String,
method_type: String,
can_return: bool,
is_static: bool,
is_async: bool,
hook_id: int,
method_prefix := METHOD_PREFIX,
enable_hook_check := false,
) -> String:
var type_string := " -> %s" % method_type if not method_type.is_empty() else ""
var return_string := "return " if can_return else ""
var static_string := "static " if is_static else ""
var await_string := "await " if is_async else ""
var async_string := "_async" if is_async else ""
var hook_check := "if _ModLoaderHooks.any_mod_hooked:\n\t\t" if enable_hook_check else ""
var hook_check_else := get_hook_check_else_string(
return_string, await_string, method_prefix, method_name, method_arg_string_names_only
) if enable_hook_check else ""
return """
{STATIC}func {METHOD_NAME}({METHOD_PARAMS}){RETURN_TYPE_STRING}:
{HOOK_CHECK}{RETURN}{AWAIT}_ModLoaderHooks.call_hooks{ASYNC}({METHOD_PREFIX}{METHOD_NAME}, [{METHOD_ARGS}], {HOOK_ID}){HOOK_CHECK_ELSE}
""".format({
"METHOD_PREFIX": method_prefix,
"METHOD_NAME": method_name,
"METHOD_PARAMS": method_arg_string_with_defaults_and_types,
"RETURN_TYPE_STRING": type_string,
"METHOD_ARGS": method_arg_string_names_only,
"STATIC": static_string,
"RETURN": return_string,
"AWAIT": await_string,
"ASYNC": async_string,
"HOOK_ID": hook_id,
"HOOK_CHECK": hook_check,
"HOOK_CHECK_ELSE": hook_check_else
})
static func get_previous_line_to(text: String, index: int) -> String:
if index <= 0 or index >= text.length():
return ""
var start_index := index - 1
# Find the end of the previous line
while start_index > 0 and text[start_index] != "\n":
start_index -= 1
if start_index == 0:
return ""
start_index -= 1
# Find the start of the previous line
var end_index := start_index
while start_index > 0 and text[start_index - 1] != "\n":
start_index -= 1
return text.substr(start_index, end_index - start_index + 1)
static func is_func_marked_moddable(method_start_idx, text) -> bool:
var prevline := get_previous_line_to(text, method_start_idx)
if prevline.contains("@not-moddable"):
return false
if not REQUIRE_EXPLICIT_ADDITION:
return true
return prevline.contains("@moddable")
static func get_index_at_method_start(method_name: String, text: String) -> int:
var result := match_func_with_whitespace(method_name, text)
if result:
return text.find("\n", result.get_end())
else:
return -1
static func is_top_level_func(text: String, result_start_index: int, is_static := false) -> bool:
if is_static:
result_start_index = text.rfind("static", result_start_index)
var line_start_index := text.rfind("\n", result_start_index) + 1
var pre_func_length := result_start_index - line_start_index
if pre_func_length > 0:
return false
return true
# Make sure to only pass one line
static func is_comment(text: String, start_index: int) -> bool:
# Check for # before the start_index
if text.rfind("#", start_index) == -1:
return false
return true
# Get the left side substring of a line from a given start index
static func get_line_left(text: String, start: int) -> String:
var line_start_index := text.rfind("\n", start) + 1
return text.substr(line_start_index, start - line_start_index)
# Check if a static void type is declared
func is_void(source_code: String, func_def_closing_paren_index: int, func_body_start_index: int) -> bool:
var func_def_end_index := func_body_start_index - 1 # func_body_start_index - 1 should be `:` position.
var type_zone := source_code.substr(func_def_closing_paren_index, func_def_end_index - func_def_closing_paren_index)
for void_match in regex_keyword_void.search_all(type_zone):
if is_comment(
get_line_left(type_zone, void_match.get_start()),
void_match.get_start()
):
continue
return true
return false
func can_return(source_code: String, method_name: String, func_def_closing_paren_index: int, func_body_start_index: int) -> bool:
if method_name == "_init":
return false
if is_void(source_code, func_def_closing_paren_index, func_body_start_index):
return false
return true
static func get_return_type_string(return_data: Dictionary) -> String:
if return_data.type == 0:
return ""
var type_base: String
if return_data.has("class_name") and not str(return_data.class_name).is_empty():
type_base = str(return_data.class_name)
else:
type_base = get_type_name(return_data.type)
var type_hint: String = "" if return_data.hint_string.is_empty() else ("[%s]" % return_data.hint_string)
return "%s%s" % [type_base, type_hint]
func collect_getters_and_setters(text: String) -> Dictionary:
var result := {}
# a valid match has 2 or 4 groups, split into the method names and the rest of the line
# (var example: set = )(example_setter)(, get = )(example_getter)
# if things between the names are empty or commented, exclude them
for mat in regex_getter_setter.search_all(text):
if mat.get_string(1).is_empty() or mat.get_string(1).contains("#"):
continue
result[mat.get_string(2)] = true
if mat.get_string(3).is_empty() or mat.get_string(3).contains("#"):
continue
result[mat.get_string(4)] = true
return result
static func get_hook_check_else_string(
return_string: String,
await_string: String,
method_prefix: String,
method_name: String,
method_arg_string_names_only: String
) -> String:
return "\n\telse:\n\t\t{RETURN}{AWAIT}{METHOD_PREFIX}{METHOD_NAME}({METHOD_ARGS})".format(
{
"RETURN": return_string,
"AWAIT": await_string,
"METHOD_PREFIX": method_prefix,
"METHOD_NAME": method_name,
"METHOD_ARGS": method_arg_string_names_only
}
)
# This function was taken from
# https://github.com/godotengine/godot/blob/7e67b496ff7e35f66b88adcbdd5b252d01739cbb/modules/gdscript/tests/scripts/utils.notest.gd#L69
# It is used instead of type_string because type_string does not exist in Godot 4.1
static func get_type_name(type: Variant.Type) -> String:
match type:
TYPE_NIL:
return "Nil" # `Nil` in core, `null` in GDScript.
TYPE_BOOL:
return "bool"
TYPE_INT:
return "int"
TYPE_FLOAT:
return "float"
TYPE_STRING:
return "String"
TYPE_VECTOR2:
return "Vector2"
TYPE_VECTOR2I:
return "Vector2i"
TYPE_RECT2:
return "Rect2"
TYPE_RECT2I:
return "Rect2i"
TYPE_VECTOR3:
return "Vector3"
TYPE_VECTOR3I:
return "Vector3i"
TYPE_TRANSFORM2D:
return "Transform2D"
TYPE_VECTOR4:
return "Vector4"
TYPE_VECTOR4I:
return "Vector4i"
TYPE_PLANE:
return "Plane"
TYPE_QUATERNION:
return "Quaternion"
TYPE_AABB:
return "AABB"
TYPE_BASIS:
return "Basis"
TYPE_TRANSFORM3D:
return "Transform3D"
TYPE_PROJECTION:
return "Projection"
TYPE_COLOR:
return "Color"
TYPE_STRING_NAME:
return "StringName"
TYPE_NODE_PATH:
return "NodePath"
TYPE_RID:
return "RID"
TYPE_OBJECT:
return "Object"
TYPE_CALLABLE:
return "Callable"
TYPE_SIGNAL:
return "Signal"
TYPE_DICTIONARY:
return "Dictionary"
TYPE_ARRAY:
return "Array"
TYPE_PACKED_BYTE_ARRAY:
return "PackedByteArray"
TYPE_PACKED_INT32_ARRAY:
return "PackedInt32Array"
TYPE_PACKED_INT64_ARRAY:
return "PackedInt64Array"
TYPE_PACKED_FLOAT32_ARRAY:
return "PackedFloat32Array"
TYPE_PACKED_FLOAT64_ARRAY:
return "PackedFloat64Array"
TYPE_PACKED_STRING_ARRAY:
return "PackedStringArray"
TYPE_PACKED_VECTOR2_ARRAY:
return "PackedVector2Array"
TYPE_PACKED_VECTOR3_ARRAY:
return "PackedVector3Array"
TYPE_PACKED_COLOR_ARRAY:
return "PackedColorArray"
38: # TYPE_PACKED_VECTOR4_ARRAY
return "PackedVector4Array"
push_error("Argument `type` is invalid. Use `TYPE_*` constants.")
return "<unknown type %s>" % type

View File

@@ -0,0 +1 @@
uid://gxlbhvqctoix

View File

@@ -0,0 +1,106 @@
class_name ModLoaderUtils
extends Node
const LOG_NAME := "ModLoader:ModLoaderUtils"
## This is a dummy func. It is exclusively used to show notes in the code that
## stay visible after decompiling a PCK, as is primarily intended to assist new
## modders in understanding and troubleshooting issues
static func _code_note(_msg:String):
pass
## Returns an empty [String] if the key does not exist or is not type of [String]
static func get_string_from_dict(dict: Dictionary, key: String) -> String:
if not dict.has(key):
return ""
if not dict[key] is String:
return ""
return dict[key]
## Returns an empty [Array] if the key does not exist or is not type of [Array]
static func get_array_from_dict(dict: Dictionary, key: String) -> Array:
if not dict.has(key):
return []
if not dict[key] is Array:
return []
return dict[key]
## Returns an empty [Dictionary] if the key does not exist or is not type of [Dictionary]
static func get_dict_from_dict(dict: Dictionary, key: String) -> Dictionary:
if not dict.has(key):
return {}
if not dict[key] is Dictionary:
return {}
return dict[key]
## Works like [method Dictionary.has_all],
## but allows for more specific errors if a field is missing
static func dict_has_fields(dict: Dictionary, required_fields: Array[String]) -> bool:
var missing_fields := get_missing_dict_fields(dict, required_fields)
if missing_fields.size() > 0:
ModLoaderLog.fatal("Dictionary is missing required fields: %s" % str(missing_fields), LOG_NAME)
return false
return true
static func get_missing_dict_fields(dict: Dictionary, required_fields: Array[String]) -> Array[String]:
var missing_fields := required_fields.duplicate()
for key in dict.keys():
if(required_fields.has(key)):
missing_fields.erase(key)
return missing_fields
## Register an array of classes to the global scope, since Godot only does that in the editor.
static func register_global_classes_from_array(new_global_classes: Array) -> void:
var registered_classes: Array = ProjectSettings.get_setting("_global_script_classes")
var registered_class_icons: Dictionary = ProjectSettings.get_setting("_global_script_class_icons")
for new_class in new_global_classes:
if not _is_valid_global_class_dict(new_class):
continue
for old_class in registered_classes:
if old_class.get_class() == new_class.get_class():
if OS.has_feature("editor"):
ModLoaderLog.info('Class "%s" to be registered as global was already registered by the editor. Skipping.' % new_class.get_class(), LOG_NAME)
else:
ModLoaderLog.info('Class "%s" to be registered as global already exists. Skipping.' % new_class.get_class(), LOG_NAME)
continue
registered_classes.append(new_class)
registered_class_icons[new_class.get_class()] = "" # empty icon, does not matter
ProjectSettings.set_setting("_global_script_classes", registered_classes)
ProjectSettings.set_setting("_global_script_class_icons", registered_class_icons)
## Checks if all required fields are in the given [Dictionary]
## Format: [code]{ "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }[/code]
static func _is_valid_global_class_dict(global_class_dict: Dictionary) -> bool:
var required_fields := ["base", "class", "language", "path"]
if not global_class_dict.has_all(required_fields):
ModLoaderLog.fatal("Global class to be registered is missing one of %s" % required_fields, LOG_NAME)
return false
if not _ModLoaderFile.file_exists(global_class_dict.path):
ModLoaderLog.fatal('Class "%s" to be registered as global could not be found at given path "%s"' %
[global_class_dict.get_class, global_class_dict.path], LOG_NAME)
return false
return true

View File

@@ -0,0 +1 @@
uid://bk8dltcgr6n5d

View File

@@ -0,0 +1,293 @@
class_name _ModLoaderPath
extends RefCounted
# This Class provides util functions for working with paths.
# Currently all of the included functions are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:Path"
const MOD_CONFIG_DIR_PATH := "user://mod_configs"
const MOD_CONFIG_DIR_PATH_OLD := "user://configs"
# Get the path to a local folder. Primarily used to get the (packed) mods
# folder, ie "res://mods" or the OS's equivalent, as well as the configs path
static func get_local_folder_dir(subfolder: String = "") -> String:
return get_game_install_dir().path_join(subfolder)
static func get_game_install_dir() -> String:
var game_install_directory := OS.get_executable_path().get_base_dir()
if OS.get_name() == "macOS":
game_install_directory = game_install_directory.get_base_dir().get_base_dir()
if game_install_directory.ends_with(".app"):
game_install_directory = game_install_directory.get_base_dir()
# Fix for running the game through the Godot editor (as the EXE path would be
# the editor's own EXE, which won't have any mod ZIPs)
# if OS.is_debug_build():
if OS.has_feature("editor"):
game_install_directory = "res://"
return game_install_directory
# Get the path where override.cfg will be stored.
# Not the same as the local folder dir (for mac)
static func get_override_path() -> String:
var base_path := ""
if OS.has_feature("editor"):
base_path = ProjectSettings.globalize_path("res://")
else:
# this is technically different to res:// in macos, but we want the
# executable dir anyway, so it is exactly what we need
base_path = OS.get_executable_path().get_base_dir()
return base_path.path_join("override.cfg")
# Provide a path, get the file name at the end of the path
static func get_file_name_from_path(path: String, make_lower_case := true, remove_extension := false) -> String:
var file_name := path.get_file()
if make_lower_case:
file_name = file_name.to_lower()
if remove_extension:
file_name = file_name.trim_suffix("." + file_name.get_extension())
return file_name
# Provide a zip_path to a workshop mod, returns the steam_workshop_id
static func get_steam_workshop_id(zip_path: String) -> String:
if not zip_path.contains("/Steam/steamapps/workshop/content"):
return ""
return zip_path.get_base_dir().split("/")[-1]
# Get a flat array of all files in the target directory.
# Source: https://gist.github.com/willnationsdev/00d97aa8339138fd7ef0d6bd42748f6e
static func get_flat_view_dict(p_dir := "res://", p_match := "", p_match_is_regex := false) -> PackedStringArray:
var data: PackedStringArray = []
var regex: RegEx
if p_match_is_regex:
regex = RegEx.new()
var _compile_error: int = regex.compile(p_match)
if not regex.is_valid():
return data
var dirs := [p_dir]
var first := true
while not dirs.is_empty():
var dir_name: String = dirs.back()
dirs.pop_back()
var dir := DirAccess.open(dir_name)
if not dir == null:
var _dirlist_error: int = dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
var file_name := dir.get_next()
while file_name != "":
if not dir_name == "res://":
first = false
# ignore hidden, temporary, or system content
if not file_name.begins_with(".") and not file_name.get_extension() in ["tmp", "import"]:
# If a directory, then add to list of directories to visit
if dir.current_is_dir():
dirs.push_back(dir.get_current_dir().path_join(file_name))
# If a file, check if we already have a record for the same name
else:
var path := dir.get_current_dir() + ("/" if not first else "") + file_name
# grab all
if not p_match:
data.append(path)
# grab matching strings
elif not p_match_is_regex and file_name.find(p_match, 0) != -1:
data.append(path)
# grab matching regex
else:
var regex_match := regex.search(path)
if regex_match != null:
data.append(path)
# Move on to the next file in this directory
file_name = dir.get_next()
# We've exhausted all files in this directory. Close the iterator.
dir.list_dir_end()
return data
# Returns an array of file paths inside the src dir
static func get_file_paths_in_dir(src_dir_path: String) -> Array:
var file_paths := []
var dir := DirAccess.open(src_dir_path)
if dir == null:
ModLoaderLog.error("Encountered an error (%s) when attempting to open a directory, with the path: %s" % [error_string(DirAccess.get_open_error()), src_dir_path], LOG_NAME)
return file_paths
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
var file_name := dir.get_next()
while (file_name != ""):
if not dir.current_is_dir():
file_paths.push_back(src_dir_path.path_join(file_name))
file_name = dir.get_next()
return file_paths
# Returns an array of directory paths inside the src dir
static func get_dir_paths_in_dir(src_dir_path: String) -> Array:
var dir_paths := []
var dir := DirAccess.open(src_dir_path)
if dir == null:
ModLoaderLog.error("Encountered an error (%s) when attempting to open a directory, with the path: %s" % [error_string(DirAccess.get_open_error()), src_dir_path], LOG_NAME)
return dir_paths
dir.list_dir_begin()
var file_name := dir.get_next()
while (file_name != ""):
if file_name == "." or file_name == "..":
file_name = dir.get_next()
continue
if dir.current_is_dir():
dir_paths.push_back(src_dir_path.path_join(file_name))
file_name = dir.get_next()
return dir_paths
# Get the path to the mods folder, with any applicable overrides applied
static func get_path_to_mods() -> String:
var mods_folder_path := get_local_folder_dir("mods")
if ModLoaderStore:
if ModLoaderStore.ml_options.override_path_to_mods:
mods_folder_path = ModLoaderStore.ml_options.override_path_to_mods
return mods_folder_path
# Finds the global paths to all zips in provided directory
static func get_zip_paths_in(folder_path: String) -> Array[String]:
var zip_paths: Array[String] = []
var files := Array(DirAccess.get_files_at(folder_path))\
.filter(
func(file_name: String):
return is_zip(file_name)
).map(
func(file_name: String):
return ProjectSettings.globalize_path(folder_path.path_join(file_name))
)
# only .assign()ing to a typed array lets us return Array[String] instead of just Array
zip_paths.assign(files)
return zip_paths
static func get_mod_paths_from_all_sources() -> Array[String]:
var mod_paths: Array[String] = []
var mod_dirs := get_dir_paths_in_dir(get_unpacked_mods_dir_path())
if ModLoaderStore.has_feature.editor or ModLoaderStore.ml_options.load_from_unpacked:
mod_paths.append_array(mod_dirs)
else:
ModLoaderLog.info("Loading mods from \"res://mods-unpacked\" is disabled.", LOG_NAME)
if ModLoaderStore.ml_options.load_from_local:
var mods_dir := get_path_to_mods()
if not DirAccess.dir_exists_absolute(mods_dir):
ModLoaderLog.info("The directory for mods at path \"%s\" does not exist." % mods_dir, LOG_NAME)
else:
mod_paths.append_array(get_zip_paths_in(mods_dir))
if ModLoaderStore.ml_options.load_from_steam_workshop:
mod_paths.append_array(_ModLoaderSteam.find_steam_workshop_zips())
return mod_paths
static func get_path_to_mod_manifest(mod_id: String) -> String:
return get_path_to_mods().path_join(mod_id).path_join("manifest.json")
static func get_unpacked_mods_dir_path() -> String:
return ModLoaderStore.UNPACKED_DIR
# Get the path to the configs folder, with any applicable overrides applied
static func get_path_to_configs() -> String:
if _ModLoaderFile.dir_exists(MOD_CONFIG_DIR_PATH_OLD):
handle_mod_config_path_deprecation()
var configs_path := MOD_CONFIG_DIR_PATH
if ModLoaderStore:
if ModLoaderStore.ml_options.override_path_to_configs:
configs_path = ModLoaderStore.ml_options.override_path_to_configs
return configs_path
# Get the path to a mods config folder
static func get_path_to_mod_configs_dir(mod_id: String) -> String:
return get_path_to_configs().path_join(mod_id)
# Get the path to a mods config file
static func get_path_to_mod_config_file(mod_id: String, config_name: String) -> String:
var mod_config_dir := get_path_to_mod_configs_dir(mod_id)
return mod_config_dir.path_join(config_name + ".json")
# Get the path to the zip file that contains the vanilla scripts with
# added mod hooks, considering all overrides
static func get_path_to_hook_pack() -> String:
var path := get_game_install_dir()
if not ModLoaderStore.ml_options.override_path_to_hook_pack.is_empty():
path = ModLoaderStore.ml_options.override_path_to_hook_pack
var name := ModLoaderStore.MOD_HOOK_PACK_NAME
if not ModLoaderStore.ml_options.override_hook_pack_name.is_empty():
name = ModLoaderStore.ml_options.override_hook_pack_name
return path.path_join(name)
# Returns the mod directory name ("some-mod") from a given path (e.g. "res://mods-unpacked/some-mod/extensions/extension.gd")
static func get_mod_dir(path: String) -> String:
var initial := ModLoaderStore.UNPACKED_DIR
var ending := "/"
var start_index: int = path.find(initial)
if start_index == -1:
ModLoaderLog.error("Initial string not found.", LOG_NAME)
return ""
start_index += initial.length()
var end_index: int = path.find(ending, start_index)
if end_index == -1:
ModLoaderLog.error("Ending string not found.", LOG_NAME)
return ""
var found_string: String = path.substr(start_index, end_index - start_index)
return found_string
# Checks if the path ends with .zip
static func is_zip(path: String) -> bool:
return path.get_extension() == "zip"
static func handle_mod_config_path_deprecation() -> void:
ModLoaderDeprecated.deprecated_message("The mod config path has been moved to \"%s\".
The Mod Loader will attempt to rename the config directory." % MOD_CONFIG_DIR_PATH, "7.0.0")
var error := DirAccess.rename_absolute(MOD_CONFIG_DIR_PATH_OLD, MOD_CONFIG_DIR_PATH)
if not error == OK:
ModLoaderLog.error("Failed to rename the config directory with error \"%s\"." % [error_string(error)], LOG_NAME)
else:
ModLoaderLog.success("Successfully renamed config directory to \"%s\"." % MOD_CONFIG_DIR_PATH, LOG_NAME)

View File

@@ -0,0 +1 @@
uid://dahg6tbvgiy3q

View File

@@ -0,0 +1,56 @@
class_name _ModLoaderSceneExtension
extends RefCounted
# This Class provides methods for working with scene extensions.
# Currently all of the included methods are internal and should only be used by the mod loader itself.
const LOG_NAME := "ModLoader:SceneExtension"
# Iterates over the list of scenes to refresh them from storage.
# Used to apply script extensions to preloaded scenes.
static func refresh_scenes() -> void:
for scene_path in ModLoaderStore.scenes_to_refresh:
# Refresh cached scenes from storage
var _scene_from_file: PackedScene = ResourceLoader.load(
scene_path, "", ResourceLoader.CACHE_MODE_REPLACE
)
ModLoaderLog.debug("Refreshed scene at path: %s" % scene_path, LOG_NAME)
# Iterates over the list of scenes to modify and applies the specified edits to each scene.
static func handle_scene_extensions() -> void:
for scene_path in ModLoaderStore.scenes_to_modify.keys():
for scene_edit_callable in ModLoaderStore.scenes_to_modify[scene_path]:
var cached_scene: PackedScene = load(scene_path)
var cached_scene_instance: Node = cached_scene.instantiate()
var edited_scene: Node = scene_edit_callable.call(cached_scene_instance)
if not edited_scene:
ModLoaderLog.fatal(
(
'Scene extension of "%s" failed since the edit callable "%s" does not return the modified scene_instance'
% [scene_path, scene_edit_callable.get_method()]
),
LOG_NAME
)
return
_save_scene(edited_scene, scene_path)
# Saves a modified scene to resource cache.
# Further attempts to load this scene by path will instead return this resource.
#
# Parameters:
# - modified_scene (Node): The modified scene instance to be saved.
# - scene_path (String): The path to the scene file that will be replaced.
#
# Returns: void
static func _save_scene(modified_scene: Node, scene_path: String) -> void:
var packed_scene := PackedScene.new()
var _pack_error := packed_scene.pack(modified_scene)
ModLoaderLog.debug("packing scene -> %s" % packed_scene, LOG_NAME)
packed_scene.take_over_path(scene_path)
ModLoaderLog.debug(
"save_scene - taking over path - new path -> %s" % packed_scene.resource_path, LOG_NAME
)
ModLoaderStore.saved_objects.append(packed_scene)

View File

@@ -0,0 +1 @@
uid://daijyovwv2vnr

View File

@@ -0,0 +1,156 @@
class_name _ModLoaderScriptExtension
extends RefCounted
# This Class provides methods for working with script extensions.
# Currently all of the included methods are internal and should only be used by the mod loader itself.
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)
else:
ModLoaderLog.error(
"The child script path '%s' does not exist" % [extension_path], LOG_NAME
)
# Sort by inheritance
InheritanceSorting.new(extension_paths)
# Load and install all extensions
for extension in extension_paths:
var script: Script = apply_extension(extension)
_reload_vanilla_child_classes_for(script)
# Sorts script paths by their ancestors. Scripts are organized by their common
# ancestors then sorted such that scripts extending script A will be before
# a script extending script B if A is an ancestor of B.
class InheritanceSorting:
var stack_cache := {}
# This dictionary's keys are mod_ids and it stores the corresponding position in the load_order
var load_order := {}
func _init(inheritance_array_to_sort: Array) -> void:
_populate_load_order_table()
inheritance_array_to_sort.sort_custom(check_inheritances)
# Comparator function. return true if a should go before b. This may
# enforce conditions beyond the stated inheritance relationship.
func check_inheritances(extension_a: String, extension_b: String) -> bool:
var a_stack := cached_inheritances_stack(extension_a)
var b_stack := cached_inheritances_stack(extension_b)
var last_index: int
for index in a_stack.size():
if index >= b_stack.size():
return false
if a_stack[index] != b_stack[index]:
return a_stack[index] < b_stack[index]
last_index = index
if last_index < b_stack.size() - 1:
return true
return compare_mods_order(extension_a, extension_b)
# Returns a list of scripts representing all the ancestors of the extension
# script with the most recent ancestor last.
#
# Results are stored in a cache keyed by extension path
func cached_inheritances_stack(extension_path: String) -> Array:
if stack_cache.has(extension_path):
return stack_cache[extension_path]
var stack := []
var parent_script: Script = load(extension_path)
while parent_script:
stack.push_front(parent_script.resource_path)
parent_script = parent_script.get_base_script()
stack.pop_back()
stack_cache[extension_path] = stack
return stack
# Secondary comparator function for resolving scripts extending the same vanilla script
# Will return whether a comes before b in the load order
func compare_mods_order(extension_a: String, extension_b: String) -> bool:
var mod_a_id: String = _ModLoaderPath.get_mod_dir(extension_a)
var mod_b_id: String = _ModLoaderPath.get_mod_dir(extension_b)
return load_order[mod_a_id] < load_order[mod_b_id]
# Populate a load order dictionary for faster access and comparison between mod ids
func _populate_load_order_table() -> void:
var mod_index := 0
for mod in ModLoaderStore.mod_load_order:
load_order[mod.dir_name] = mod_index
mod_index += 1
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 = 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.
child_script.reload()
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()

View File

@@ -0,0 +1 @@
uid://dinxouhn4hk1d

View File

@@ -0,0 +1,108 @@
class_name _ModLoaderSteam
extends Node
const LOG_NAME := "ModLoader:ThirdParty:Steam"
# Methods related to Steam and the Steam Workshop
# Get mod zip paths from steam workshop folders.
# folder structure of a workshop item
# <workshop folder>/<steam app id>/<workshop item id>/<mod>.zip
static func find_steam_workshop_zips() -> Array[String]:
# TODO: use new diraccess methods + filter
var zip_paths: Array[String] = []
var workshop_folder_path := _get_path_to_workshop()
ModLoaderLog.info("Checking workshop items, with path: \"%s\"" % workshop_folder_path, LOG_NAME)
var workshop_dir := DirAccess.open(workshop_folder_path)
if workshop_dir == null:
ModLoaderLog.error("Can't open workshop folder %s (Error: %s)" % [workshop_folder_path, error_string(DirAccess.get_open_error())], LOG_NAME)
return []
var workshop_dir_listdir_error := workshop_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
if not workshop_dir_listdir_error == OK:
ModLoaderLog.error("Can't read workshop folder %s (Error: %s)" % [workshop_folder_path, error_string(workshop_dir_listdir_error)], LOG_NAME)
return []
# Loop 1: Workshop folders
while true:
# Get the next workshop item folder
var item_dir := workshop_dir.get_next()
if item_dir == '':
break
var item_path := workshop_dir.get_current_dir() + "/" + item_dir
ModLoaderLog.info("Checking workshop item path: \"%s\"" % item_path, LOG_NAME)
# Only check directories
if not workshop_dir.current_is_dir():
continue
# Loop 2: ZIPs inside the workshop folders
zip_paths.append_array(_ModLoaderPath.get_zip_paths_in(ProjectSettings.globalize_path(item_path)))
workshop_dir.list_dir_end()
return zip_paths
# Get the path to the Steam workshop folder. Only works for Steam games, as it
# traverses directories relative to where a Steam game and its workshop content
# would be installed. Based on code by Blobfish (developer of Brotato).
# For reference, these are the paths of a Steam game and its workshop folder:
# GAME = Steam/steamapps/common/GameName
# WORKSHOP = Steam/steamapps/workshop/content/AppID
# Eg. Brotato:
# GAME = Steam/steamapps/common/Brotato
# WORKSHOP = Steam/steamapps/workshop/content/1942280
static func _get_path_to_workshop() -> String:
if ModLoaderStore.ml_options.override_path_to_workshop:
return ModLoaderStore.ml_options.override_path_to_workshop
var game_install_directory := _ModLoaderPath.get_local_folder_dir()
var path := ""
# Traverse up to the steamapps directory (ie. `cd ..\..\` on Windows)
var path_array := game_install_directory.split("/")
path_array.resize(path_array.size() - 3)
# Reconstruct the path, now that it has "common/GameName" removed
path = "/".join(path_array)
# Append the game's workshop path
path = path.path_join("workshop/content/" + _get_steam_app_id())
return path
# Gets the steam app ID from ml_options or the steam_data.json, which should be in the root
# directory (ie. res://steam_data.json). This file is used by Godot Workshop
# Utility (GWU), which was developed by Brotato developer Blobfish:
# https://github.com/thomasgvd/godot-workshop-utility
static func _get_steam_app_id() -> String:
# Check if the steam id is stored in the options
if ModLoaderStore.ml_options.steam_id:
return str(ModLoaderStore.ml_options.steam_id)
ModLoaderLog.debug("No Steam ID specified in the Mod Loader options. Attempting to read the steam_data.json file next.", LOG_NAME)
# If the steam_id is not stored in the options try to get it from the steam_data.json file.
var game_install_directory := _ModLoaderPath.get_local_folder_dir()
var steam_app_id := ""
var file := FileAccess.open(game_install_directory.path_join("steam_data.json"), FileAccess.READ)
if not file == null:
var test_json_conv = JSON.new()
test_json_conv.parse(file.get_as_text())
var file_content: Dictionary = test_json_conv.get_data()
file.close()
if not file_content.has("app_id"):
ModLoaderLog.error("The steam_data file does not contain an app ID. Mod uploading will not work.", LOG_NAME)
return ""
steam_app_id = str(file_content.app_id)
else :
ModLoaderLog.error("Can't open steam_data file, \"%s\". Please make sure the file exists and is valid." % game_install_directory.path_join("steam_data.json"), LOG_NAME)
return steam_app_id

View File

@@ -0,0 +1 @@
uid://br0xd56w758rg

View File

@@ -0,0 +1,256 @@
## ModLoader - A mod loader for GDScript
#
# Written in 2021 by harrygiel <harrygiel@gmail.com>,
# in 2021 by Mariusz Chwalba <mariusz@chwalba.net>,
# in 2022 by Vladimir Panteleev <git@cy.md>,
# in 2023 by KANA <kai@kana.jetzt>,
# in 2023 by Darkly77,
# in 2023 by otDan <otdanofficial@gmail.com>,
# in 2023 by Qubus0/Ste
#
# To the extent possible under law, the author(s) have
# dedicated all copyright and related and neighboring
# rights to this software to the public domain worldwide.
# This software is distributed without any warranty.
#
# You should have received a copy of the CC0 Public
# Domain Dedication along with this software. If not, see
# <http://creativecommons.org/publicdomain/zero/1.0/>.
extends Node
## Emitted if something is logged with [ModLoaderLog]
signal logged(entry: ModLoaderLog.ModLoaderLogEntry)
## Emitted if the [member ModData.current_config] of any mod changed.
## Use the [member ModConfig.mod_id] of the [ModConfig] to check if the config of your mod has changed.
signal current_config_changed(config: ModConfig)
## Emitted when new mod hooks are created. A game restart is required to load them.
signal new_hooks_created
const LOG_NAME := "ModLoader"
func _init() -> void:
# if mods are not enabled - don't load mods
if ModLoaderStore.REQUIRE_CMD_LINE and not _ModLoaderCLI.is_running_with_command_line_arg("--enable-mods"):
return
# Only load the hook pack if not in the editor
# We can't use it in the editor - see https://github.com/godotengine/godot/issues/19815
# Mod devs can use the Dev Tool to generate hooks in the editor.
if not ModLoaderStore.has_feature.editor and _ModLoaderFile.file_exists(_ModLoaderPath.get_path_to_hook_pack()):
_load_mod_hooks_pack()
# Rotate the log files once on startup.
ModLoaderLog._rotate_log_file()
if not ModLoaderStore.ml_options.enable_mods:
ModLoaderLog.info("Mods are currently disabled", LOG_NAME)
return
# Ensure the ModLoaderStore and ModLoader autoloads are in the correct position.
_ModLoaderGodot.check_autoload_positions()
# Log the autoloads order.
ModLoaderLog.debug_json_print("Autoload order", _ModLoaderGodot.get_autoload_array(), LOG_NAME)
# Log game install dir
ModLoaderLog.info("game_install_directory: %s" % _ModLoaderPath.get_local_folder_dir(), LOG_NAME)
# Load user profiles into ModLoaderStore
if ModLoaderUserProfile.is_initialized():
var _success_user_profile_load := ModLoaderUserProfile._load()
# Create the default user profile if it does not already exist.
# This should only occur on the first run or if the JSON file was manually edited.
if not ModLoaderStore.user_profiles.has("default"):
var _success_user_profile_create := ModLoaderUserProfile.create_profile("default")
# --- Start loading mods ---
var loaded_count := 0
# mod_path can be a directory in mods-unpacked or a mod.zip
var mod_paths := _ModLoaderPath.get_mod_paths_from_all_sources()
ModLoaderLog.debug("Found %s mods at the following paths:\n\t - %s" % [mod_paths.size(), "\n\t - ".join(mod_paths)], LOG_NAME)
for mod_path in mod_paths:
var is_zip := _ModLoaderPath.is_zip(mod_path)
# Load manifest file
var manifest_data: Dictionary = _ModLoaderFile.load_manifest_file(mod_path)
var manifest := ModManifest.new(manifest_data, mod_path)
if not manifest.validation_messages_error.is_empty():
ModLoaderLog.error(
"The mod from path \"%s\" cannot be loaded. Manifest validation failed with the following errors:\n\t - %s" %
[mod_path, "\n\t - ".join(manifest.validation_messages_error)], LOG_NAME
)
# Init ModData
var mod := ModData.new(manifest, mod_path)
if not mod.load_errors.is_empty():
ModLoaderStore.ml_options.disabled_mods.append(mod.manifest.get_mod_id())
ModLoaderLog.error(
"The mod from path \"%s\" cannot be loaded. ModData initialization has failed with the following errors:\n\t - %s" %
[mod_path, "\n\t - ".join(mod.load_errors)], LOG_NAME
)
# Using mod.dir_name here allows us to store the ModData even if manifest validation fails.
ModLoaderStore.mod_data[mod.dir_name] = mod
if mod.is_loadable:
if is_zip:
var is_mod_loaded_successfully := ProjectSettings.load_resource_pack(mod_path, false)
if not is_mod_loaded_successfully:
ModLoaderLog.error("Failed to load mod zip from path \"%s\" into the virtual filesystem." % mod_path, LOG_NAME)
continue
# Notifies developer of an issue with Godot, where using `load_resource_pack`
# in the editor WIPES the entire virtual res:// directory the first time you
# use it. This means that unpacked mods are no longer accessible, because they
# no longer exist in the file system. So this warning basically says
# "don't use ZIPs with unpacked mods!"
# https://github.com/godotengine/godot/issues/19815
# https://github.com/godotengine/godot/issues/16798
if ModLoaderStore.has_feature.editor:
ModLoaderLog.hint(
"Loading any resource packs (.zip/.pck) with `load_resource_pack` will WIPE the entire virtual res:// directory. " +
"If you have any unpacked mods in %s, they will not be loaded.Please unpack your mod ZIPs instead, and add them to %s" %
[_ModLoaderPath.get_unpacked_mods_dir_path(), _ModLoaderPath.get_unpacked_mods_dir_path()], LOG_NAME, true
)
ModLoaderLog.success("%s loaded." % mod_path, LOG_NAME)
loaded_count += 1
ModLoaderLog.success("DONE: Loaded %s mod files into the virtual filesystem" % loaded_count, LOG_NAME)
# Update the mod_list for each user profile
var _success_update_mod_lists := ModLoaderUserProfile._update_mod_lists()
# Update active state of mods based on the current user profile
ModLoaderUserProfile._update_disabled_mods()
# Load all Mod Configs
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
if mod.manifest.get("config_schema") and not mod.manifest.config_schema.is_empty():
mod.load_configs()
ModLoaderLog.success("DONE: Loaded all mod configs", LOG_NAME)
# Check for mods with load_before. If a mod is listed in load_before,
# add the current mod to the dependencies of the the mod specified
# in load_before.
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
_ModLoaderDependency.check_load_before(mod)
# Run optional dependency checks.
# If a mod depends on another mod that hasn't been loaded,
# the dependent mod will be loaded regardless.
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
var _is_circular := _ModLoaderDependency.check_dependencies(mod, false)
# Run dependency checks. If a mod depends on another
# mod that hasn't been loaded, the dependent mod won't be loaded.
for dir_name in ModLoaderStore.mod_data:
var mod: ModData = ModLoaderStore.mod_data[dir_name]
if not mod.is_loadable:
continue
var _is_circular := _ModLoaderDependency.check_dependencies(mod)
# Sort mod_load_order by the importance score of the mod
ModLoaderStore.mod_load_order = _ModLoaderDependency.get_load_order(ModLoaderStore.mod_data.values())
# Log mod order
for mod_index in ModLoaderStore.mod_load_order.size():
var mod: ModData = ModLoaderStore.mod_load_order[mod_index]
ModLoaderLog.info("mod_load_order -> %s) %s" % [mod_index + 1, mod.dir_name], LOG_NAME)
# Instance every mod and add it as a node to the Mod Loader
for mod in ModLoaderStore.mod_load_order:
mod = mod as ModData
# Continue if mod is disabled
if not mod.is_active or not mod.is_loadable:
continue
ModLoaderLog.info("Initializing -> %s" % mod.manifest.get_mod_id(), LOG_NAME)
_init_mod(mod)
ModLoaderLog.debug_json_print("mod data", ModLoaderStore.mod_data, LOG_NAME)
ModLoaderLog.success("DONE: Completely finished loading mods", LOG_NAME)
_ModLoaderScriptExtension.handle_script_extensions()
ModLoaderLog.success("DONE: Installed all script extensions", LOG_NAME)
_ModLoaderSceneExtension.refresh_scenes()
_ModLoaderSceneExtension.handle_scene_extensions()
ModLoaderLog.success("DONE: Applied all scene extensions", LOG_NAME)
ModLoaderStore.is_initializing = false
new_hooks_created.connect(_ModLoaderHooks.on_new_hooks_created)
func _ready():
# Hooks must be generated after all autoloads are available.
# Variables initialized with an autoload property cause errors otherwise.
if _ModLoaderHooks.any_mod_hooked:
if OS.has_feature("editor"):
ModLoaderLog.hint("No mod hooks .zip will be created when running from the editor.", LOG_NAME)
ModLoaderLog.hint("You can test mod hooks by running the preprocessor on the vanilla scripts once.", LOG_NAME)
ModLoaderLog.hint("We recommend using the Mod Loader Dev Tool to process scripts in the editor. You can find it here: %s" % ModLoaderStore.MOD_LOADER_DEV_TOOL_URL, LOG_NAME)
else:
# Generate mod hooks
_ModLoaderModHookPacker.start()
func _load_mod_hooks_pack() -> void:
# Load mod hooks
var load_hooks_pack_success := ProjectSettings.load_resource_pack(_ModLoaderPath.get_path_to_hook_pack())
if not load_hooks_pack_success:
ModLoaderLog.error("Failed loading hooks pack from: %s" % _ModLoaderPath.get_path_to_hook_pack(), LOG_NAME)
else:
ModLoaderLog.debug("Successfully loaded hooks pack from: %s" % _ModLoaderPath.get_path_to_hook_pack(), LOG_NAME)
# Instantiate every mod and add it as a node to the Mod Loader.
func _init_mod(mod: ModData) -> void:
var mod_main_path := mod.get_required_mod_file_path(ModData.RequiredModFiles.MOD_MAIN)
var mod_overwrites_path := mod.get_optional_mod_file_path(ModData.OptionalModFiles.OVERWRITES)
# If the mod contains overwrites initialize the overwrites script
if mod.is_overwrite:
ModLoaderLog.debug("Overwrite script detected -> %s" % mod_overwrites_path, LOG_NAME)
var mod_overwrites_script := load(mod_overwrites_path)
mod_overwrites_script.new()
ModLoaderLog.debug("Initialized overwrite script -> %s" % mod_overwrites_path, LOG_NAME)
ModLoaderLog.debug("Loading script from -> %s" % mod_main_path, LOG_NAME)
var mod_main_script: GDScript = ResourceLoader.load(mod_main_path)
ModLoaderLog.debug("Loaded script -> %s" % mod_main_script, LOG_NAME)
var mod_main_instance: Node = mod_main_script.new()
mod_main_instance.name = mod.manifest.get_mod_id()
ModLoaderStore.saved_mod_mains[mod_main_path] = mod_main_instance
ModLoaderLog.debug("Adding mod main instance to ModLoader -> %s" % mod_main_instance, LOG_NAME)
add_child(mod_main_instance, true)

View File

@@ -0,0 +1 @@
uid://bd8npc3ai2vv3

View File

@@ -0,0 +1,230 @@
extends Node
# ModLoaderStore
## Singleton (autoload) for storing data. Should be added before ModLoader,
## as an autoload called `ModLoaderStore`
# Constants
# =============================================================================
# Most of these settings should never need to change, aside from the DEBUG_*
# options (which should be `false` when distributing compiled PCKs)
const MODLOADER_VERSION := "7.0.1"
# This is where mod ZIPs are unpacked to
const UNPACKED_DIR := "res://mods-unpacked/"
# Default name for the mod hook pack
const MOD_HOOK_PACK_NAME := "mod-hooks.zip"
# Set to true to require using "--enable-mods" to enable them
const REQUIRE_CMD_LINE := false
const LOG_NAME := "ModLoader:Store"
const URL_MOD_STRUCTURE_DOCS := "https://wiki.godotmodding.com/guides/modding/mod_structure"
const MOD_LOADER_DEV_TOOL_URL := "https://github.com/GodotModding/godot-mod-tool"
# Vars
# =============================================================================
# Stores arrays of hook callables that will be applied to a function,
# associated by a hash of the function name and script path
# Example:
# var modding_hooks := {
# 1917482423: [Callable, Callable],
# 3108290668: [Callable],
# }
var modding_hooks := {}
# Stores script paths and method names to be processed for hooks
# Example:
# var hooked_script_paths := {
# "res://game/game.gd": ["_ready", "do_something"],
# }
var hooked_script_paths := {}
# Order for mods to be loaded in, set by `get_load_order`
var mod_load_order := []
# Stores data for every found/loaded mod
var mod_data := {}
# Any mods that are missing their dependancies are added to this
# Example property: "mod_id": ["dep_mod_id_0", "dep_mod_id_2"]
var mod_missing_dependencies := {}
# Set to false after ModLoader._init()
# Helps to decide whether a script extension should go through the _ModLoaderScriptExtension.handle_script_extensions() process
var is_initializing := true
# Store all extenders paths
var script_extensions := []
# Stores scene paths that need to be reloaded from file.
# Used to apply extension to scripts that are attached to preloaded scenes.
var scenes_to_refresh := []
# Dictionary of callables to modify a specific scene.
# Example property: "scene_path": [Callable, Callable]
var scenes_to_modify := {}
# Things to keep to ensure they are not garbage collected (used by `save_scene`)
var saved_objects := []
# Stores all the taken over scripts for restoration
var saved_scripts := {}
# Stores main scripts for mod disabling
var saved_mod_mains := {}
# Stores script extension paths with the key being the namespace of a mod
var saved_extension_paths := {}
var logged_messages: Dictionary:
set(val):
ModLoaderDeprecated.deprecated_changed("ModLoaderStore.logged_messages", "ModLoaderLog.logged_messages", "7.0.1")
ModLoaderLog.logged_messages = val
get:
ModLoaderDeprecated.deprecated_changed("ModLoaderStore.logged_messages", "ModLoaderLog.logged_messages", "7.0.1")
return ModLoaderLog.logged_messages
# Active user profile
var current_user_profile: ModUserProfile
# List of user profiles loaded from user://mod_user_profiles.json
var user_profiles := {}
# ModLoader cache is stored in user://mod_loader_cache.json
var cache := {}
# Various options, which can be changed either via
# Godot's GUI (with the options.tres resource file), or via CLI args.
# Usage: `ModLoaderStore.ml_options.KEY`
# See: res://addons/mod_loader/options/options.tres
# See: res://addons/mod_loader/resources/options_profile.gd
var ml_options: ModLoaderOptionsProfile
var has_feature := {
"editor" = OS.has_feature("editor")
}
# Methods
# =============================================================================
func _init():
_update_ml_options_from_options_resource()
_update_ml_options_from_cli_args()
_configure_logger()
# ModLoaderStore is passed as argument so the cache data can be loaded on _init()
_ModLoaderCache.init_cache(self)
func _exit_tree() -> void:
# Save the cache to the cache file.
_ModLoaderCache.save_to_file()
# Update ModLoader's options, via the custom options resource
#
# Parameters:
# - ml_options_path: Path to the options resource. See: res://addons/mod_loader/resources/options_current.gd
func _update_ml_options_from_options_resource(ml_options_path := "res://addons/mod_loader/options/options.tres") -> void:
# Get user options for ModLoader
if not _ModLoaderFile.file_exists(ml_options_path) and not ResourceLoader.exists(ml_options_path):
ModLoaderLog.fatal(str("A critical file is missing: ", ml_options_path), LOG_NAME)
var options_resource: ModLoaderCurrentOptions = load(ml_options_path)
if options_resource.current_options == null:
ModLoaderLog.warning(str(
"No current options are set. Falling back to defaults. ",
"Edit your options at %s. " % ml_options_path
), LOG_NAME)
else:
var current_options = options_resource.current_options
if not current_options is ModLoaderOptionsProfile:
ModLoaderLog.error(str(
"Current options is not a valid Resource of type ModLoaderOptionsProfile. ",
"Please edit your options at %s. " % ml_options_path
), LOG_NAME)
# Update from the options in the resource
ml_options = current_options
# Get options overrides by feature tags
# An override is saved as Dictionary[String: ModLoaderOptionsProfile]
for feature_tag in options_resource.feature_override_options.keys():
if not feature_tag is String:
ModLoaderLog.error(str(
"Options override keys are required to be of type String. Failing key: \"%s.\" " % feature_tag,
"Please edit your options at %s. " % ml_options_path,
"Consult the documentation for all available feature tags: ",
"https://docs.godotengine.org/en/3.5/tutorials/export/feature_tags.html"
), LOG_NAME)
continue
if not OS.has_feature(feature_tag):
ModLoaderLog.info("Options override feature tag \"%s\". does not apply, skipping." % feature_tag, LOG_NAME)
continue
ModLoaderLog.info("Applying options override with feature tag \"%s\"." % feature_tag, LOG_NAME)
var override_options = options_resource.feature_override_options[feature_tag]
if not override_options is ModLoaderOptionsProfile:
ModLoaderLog.error(str(
"Options override is not a valid Resource of type ModLoaderOptionsProfile. ",
"Options override key with invalid resource: \"%s\". " % feature_tag,
"Please edit your options at %s. " % ml_options_path
), LOG_NAME)
continue
# Update from the options in the resource
ml_options = override_options
if not ml_options.customize_script_path.is_empty():
ml_options.customize_script_instance = load(ml_options.customize_script_path).new(ml_options)
# Update ModLoader's options, via CLI args
func _update_ml_options_from_cli_args() -> void:
# Disable mods
if _ModLoaderCLI.is_running_with_command_line_arg("--disable-mods"):
ml_options.enable_mods = false
# Override paths to mods
# Set via: --mods-path
# Example: --mods-path="C://path/mods"
var cmd_line_mod_path := _ModLoaderCLI.get_cmd_line_arg_value("--mods-path")
if cmd_line_mod_path:
ml_options.override_path_to_mods = cmd_line_mod_path
ModLoaderLog.info("The path mods are loaded from has been changed via the CLI arg `--mods-path`, to: " + cmd_line_mod_path, LOG_NAME)
# Override paths to configs
# Set via: --configs-path
# Example: --configs-path="C://path/configs"
var cmd_line_configs_path := _ModLoaderCLI.get_cmd_line_arg_value("--configs-path")
if cmd_line_configs_path:
ml_options.override_path_to_configs = cmd_line_configs_path
ModLoaderLog.info("The path configs are loaded from has been changed via the CLI arg `--configs-path`, to: " + cmd_line_configs_path, LOG_NAME)
# Log level verbosity
if _ModLoaderCLI.is_running_with_command_line_arg("-vvv") or _ModLoaderCLI.is_running_with_command_line_arg("--log-debug"):
ml_options.log_level = ModLoaderLog.VERBOSITY_LEVEL.DEBUG
elif _ModLoaderCLI.is_running_with_command_line_arg("-vv") or _ModLoaderCLI.is_running_with_command_line_arg("--log-info"):
ml_options.log_level = ModLoaderLog.VERBOSITY_LEVEL.INFO
elif _ModLoaderCLI.is_running_with_command_line_arg("-v") or _ModLoaderCLI.is_running_with_command_line_arg("--log-warning"):
ml_options.log_level = ModLoaderLog.VERBOSITY_LEVEL.WARNING
# Ignored mod_names in log
var ignore_mod_names := _ModLoaderCLI.get_cmd_line_arg_value("--log-ignore")
if not ignore_mod_names == "":
ml_options.ignored_mod_names_in_log = ignore_mod_names.split(",")
# Update static variables from the options
func _configure_logger() -> void:
ModLoaderLog.verbosity = ml_options.log_level
ModLoaderLog.ignored_mods = ml_options.ignored_mod_names_in_log
ModLoaderLog.hint_color = ml_options.hint_color

View File

@@ -0,0 +1 @@
uid://jv5kxtsw7sfj

View File

@@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="ModLoaderCurrentOptions" load_steps=4 format=3 uid="uid://bls83tkysflvg"]
[ext_resource type="Resource" path="res://addons/mod_loader/options/profiles/default.tres" id="1_yg7p8"]
[ext_resource type="Script" path="res://addons/mod_loader/resources/options_current.gd" id="2"]
[ext_resource type="Resource" path="res://addons/mod_loader/options/profiles/editor.tres" id="3"]
[resource]
script = ExtResource("2")
current_options = ExtResource("1_yg7p8")
feature_override_options = {
"editor": ExtResource("3")
}

View File

@@ -0,0 +1,13 @@
[gd_resource type="Resource" load_steps=2 format=3 uid="uid://bevs2mhkw1isv"]
[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1"]
[resource]
script = ExtResource("1")
enable_mods = true
log_level = 3
disabled_mods = []
steam_workshop_enabled = false
override_path_to_mods = ""
override_path_to_configs = ""
override_path_to_workshop = ""

View File

@@ -0,0 +1,14 @@
[gd_resource type="Resource" load_steps=2 format=2]
[ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1]
[resource]
script = ExtResource( 1 )
enable_mods = true
log_level = 3
disabled_mods = [ ]
steam_workshop_enabled = false
override_path_to_mods = ""
override_path_to_configs = ""
override_path_to_workshop = ""

View File

@@ -0,0 +1,14 @@
[gd_resource type="Resource" load_steps=2 format=2]
[ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1]
[resource]
script = ExtResource( 1 )
enable_mods = false
log_level = 3
disabled_mods = [ ]
steam_workshop_enabled = false
override_path_to_mods = ""
override_path_to_configs = ""
override_path_to_workshop = ""

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" load_steps=2 format=2]
[ext_resource path="res://addons/mod_loader/resources/options_profile.gd" type="Script" id=1]
[resource]
script = ExtResource( 1 )
enable_mods = true
locked_mods = [ ]
log_level = 3
disabled_mods = [ ]
allow_modloader_autoloads_anywhere = false
steam_workshop_enabled = false
override_path_to_mods = ""
override_path_to_configs = ""
override_path_to_workshop = ""
ignore_deprecated_errors = true
ignored_mod_names_in_log = [ ]

View File

@@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="ModLoaderOptionsProfile" load_steps=2 format=3 uid="uid://bodsw0jyh6rn5"]
[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1"]
[resource]
script = ExtResource("1")
enable_mods = true
locked_mods = []
log_level = 2
disabled_mods = []
allow_modloader_autoloads_anywhere = false
steam_id = 0
override_path_to_mods = ""
override_path_to_configs = ""
override_path_to_workshop = ""
ignore_deprecated_errors = false
ignored_mod_names_in_log = []
load_from_steam_workshop = false
load_from_local = true

View File

@@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="ModLoaderOptionsProfile" load_steps=2 format=3 uid="uid://cg0vv5k4o71rv"]
[ext_resource type="Script" path="res://addons/mod_loader/resources/options_profile.gd" id="1"]
[resource]
script = ExtResource("1")
enable_mods = true
locked_mods = []
log_level = 2
disabled_mods = []
allow_modloader_autoloads_anywhere = false
steam_id = 0
override_path_to_mods = ""
override_path_to_configs = ""
override_path_to_workshop = ""
ignore_deprecated_errors = false
ignored_mod_names_in_log = []
load_from_steam_workshop = true
load_from_local = true

View File

@@ -0,0 +1,82 @@
class_name ModConfig
extends Resource
##
## This Class is used to represent a configuration for a mod.[br]
## The Class provides functionality to initialize, validate, save, and remove a mod's configuration.
##
## @tutorial(Creating a Mod Config Schema with JSON-Schemas): https://wiki.godotmodding.com/guides/modding/config_json/
const LOG_NAME := "ModLoader:ModConfig"
## Name of the config - must be unique
var name: String
## The mod_id this config belongs to
var mod_id: String
## The JSON-Schema this config uses for validation
var schema: Dictionary
## The data this config holds
var data: Dictionary
## The path where the JSON file for this config is stored
var save_path: String
## False if any data is invalid
var valid := false
func _init(_mod_id: String, _data: Dictionary, _save_path: String, _schema: Dictionary = {}) -> void:
name = _ModLoaderPath.get_file_name_from_path(_save_path, true, true)
mod_id = _mod_id
schema = ModLoaderStore.mod_data[_mod_id].manifest.config_schema if _schema.is_empty() else _schema
data = _data
save_path = _save_path
var error_message := validate()
if not error_message == "":
ModLoaderLog.error("Mod Config for mod \"%s\" failed JSON Schema Validation with error message: \"%s\"" % [mod_id, error_message], LOG_NAME)
return
valid = true
func get_data_as_string() -> String:
return JSON.stringify(data)
func get_schema_as_string() -> String:
return JSON.stringify(schema)
# Empty string if validation was successful
func validate() -> String:
var json_schema := JSONSchema.new()
var error := json_schema.validate(get_data_as_string(), get_schema_as_string())
if error.is_empty():
valid = true
else:
valid = false
return error
# Runs the JSON-Schema validation and returns true if valid
func is_valid() -> bool:
if validate() == "":
valid = true
return true
valid = false
return false
## Saves the config data to the config file
func save_to_file() -> bool:
var is_success := _ModLoaderFile.save_dictionary_to_json_file(data, save_path)
return is_success
## Removes the config file
func remove_file() -> bool:
var is_success := _ModLoaderFile.remove_file(save_path)
return is_success

View File

@@ -0,0 +1 @@
uid://bjfjju1edaxwv

View File

@@ -0,0 +1,220 @@
class_name ModData
extends Resource
##
## Stores and validates all Data required to load a mod successfully
## If some of the data is invalid, [member is_loadable] will be false
const LOG_NAME := "ModLoader:ModData"
const MOD_MAIN := "mod_main.gd"
const MANIFEST := "manifest.json"
const OVERWRITES := "overwrites.gd"
# These 2 files are always required by mods.
# [i]mod_main.gd[/i] = The main init file for the mod
# [i]manifest.json[/i] = Meta data for the mod, including its dependencies
enum RequiredModFiles {
MOD_MAIN,
MANIFEST,
}
enum OptionalModFiles {
OVERWRITES
}
# Specifies the source from which the mod has been loaded:
# UNPACKED = From the mods-unpacked directory ( only when in the editor ).
# LOCAL = From the local mod zip directory, which by default is ../game_dir/mods.
# STEAM_WORKSHOP = Loaded from ../Steam/steamapps/workshop/content/1234567/[..].
enum Sources {
UNPACKED,
LOCAL,
STEAM_WORKSHOP,
}
## Name of the Mod's zip file
var zip_name := ""
## Path to the Mod's zip file
var zip_path := ""
## Directory of the mod. Has to be identical to [method ModManifest.get_mod_id]
var dir_name := ""
## Path to the mod's unpacked directory
var dir_path := ""
## False if any data is invalid
var is_loadable := true
## True if overwrites.gd exists
var is_overwrite := false
## True if mod can't be disabled or enabled in a user profile
var is_locked := false
## Flag indicating whether the mod should be loaded
var is_active := true
## Is increased for every mod depending on this mod. Highest importance is loaded first
var importance := 0
## Contents of the manifest
var manifest: ModManifest
# Updated in load_configs
## All mod configs
var configs := {}
## The currently applied mod config
var current_config: ModConfig: set = _set_current_config
## Specifies the source from which the mod has been loaded
var source: int
var load_errors: Array[String] = []
var load_warnings: Array[String] = []
func _init(_manifest: ModManifest, path: String) -> void:
manifest = _manifest
if _ModLoaderPath.is_zip(path):
zip_name = _ModLoaderPath.get_file_name_from_path(path)
zip_path = path
# Use the dir name of the passed path instead of the manifest data so we can validate
# the mod dir has the same name as the mod id in the manifest
dir_name = _ModLoaderFile.get_mod_dir_name_in_zip(zip_path)
else:
dir_name = path.split("/")[-1]
dir_path = _ModLoaderPath.get_unpacked_mods_dir_path().path_join(dir_name)
source = get_mod_source()
_has_required_files()
# We want to avoid checking if mod_dir_name == mod_id when manifest parsing has failed
# to prevent confusing error messages.
if not manifest.has_parsing_failed:
_is_mod_dir_name_same_as_id(manifest)
is_overwrite = _is_overwrite()
is_locked = manifest.get_mod_id() in ModLoaderStore.ml_options.locked_mods
if not load_errors.is_empty() or not manifest.validation_messages_error.is_empty():
is_loadable = false
# Load each mod config json from the mods config directory.
func load_configs() -> void:
# If the default values in the config schema are invalid don't load configs
if not manifest.load_mod_config_defaults():
return
var config_dir_path := _ModLoaderPath.get_path_to_mod_configs_dir(dir_name)
var config_file_paths := _ModLoaderPath.get_file_paths_in_dir(config_dir_path)
for config_file_path in config_file_paths:
_load_config(config_file_path)
# Set the current_config based on the user profile
if ModLoaderUserProfile.is_initialized() and ModLoaderConfig.has_current_config(dir_name):
current_config = ModLoaderConfig.get_current_config(dir_name)
else:
current_config = ModLoaderConfig.get_config(dir_name, ModLoaderConfig.DEFAULT_CONFIG_NAME)
# Create a new ModConfig instance for each Config JSON and add it to the configs dictionary.
func _load_config(config_file_path: String) -> void:
var config_data := _ModLoaderFile.get_json_as_dict(config_file_path)
var mod_config = ModConfig.new(
dir_name,
config_data,
config_file_path,
manifest.config_schema
)
# Add the config to the configs dictionary
configs[mod_config.name] = mod_config
# Update the mod_list of the current user profile
func _set_current_config(new_current_config: ModConfig) -> void:
ModLoaderUserProfile.set_mod_current_config(dir_name, new_current_config)
current_config = new_current_config
# We can't emit the signal if the ModLoader is not initialized yet
if ModLoader:
ModLoader.current_config_changed.emit(new_current_config)
func set_mod_state(should_activate: bool, force := false) -> bool:
if is_locked and should_activate != is_active:
ModLoaderLog.error(
"Unable to toggle mod \"%s\" since it is marked as locked. Locked mods: %s"
% [manifest.get_mod_id(), ModLoaderStore.ml_options.locked_mods], LOG_NAME)
return false
if should_activate and not is_loadable:
ModLoaderLog.error(
"Unable to activate mod \"%s\" since it has the following load errors: %s"
% [manifest.get_mod_id(), ", ".join(load_errors)], LOG_NAME)
return false
if should_activate and manifest.validation_messages_warning.size() > 0:
if not force:
ModLoaderLog.warning(
"Rejecting to activate mod \"%s\" since it has the following load warnings: %s"
% [manifest.get_mod_id(), ", ".join(load_warnings)], LOG_NAME)
return false
ModLoaderLog.info(
"Forced to activate mod \"%s\" despite the following load warnings: %s"
% [manifest.get_mod_id(), ", ".join(load_warnings)], LOG_NAME)
is_active = should_activate
return true
# Validates if [member dir_name] matches [method ModManifest.get_mod_id]
func _is_mod_dir_name_same_as_id(mod_manifest: ModManifest) -> bool:
var manifest_id := mod_manifest.get_mod_id()
if not dir_name == manifest_id:
load_errors.push_back('Mod directory name "%s" does not match the data in manifest.json. Expected "%s" (Format: {namespace}-{name})' % [ dir_name, manifest_id ])
return false
return true
func _is_overwrite() -> bool:
return _ModLoaderFile.file_exists(get_optional_mod_file_path(OptionalModFiles.OVERWRITES), zip_path)
# Confirms that all files from [member required_mod_files] exist
func _has_required_files() -> bool:
var has_required_files := true
for required_file in RequiredModFiles:
var required_file_path := get_required_mod_file_path(RequiredModFiles[required_file])
if not _ModLoaderFile.file_exists(required_file_path, zip_path):
load_errors.push_back(
"ERROR - %s is missing a required file: %s. For more information, please visit \"%s\"." %
[dir_name, required_file_path, ModLoaderStore.URL_MOD_STRUCTURE_DOCS]
)
has_required_files = false
return has_required_files
# Converts enum indices [member RequiredModFiles] into their respective file paths
# All required mod files should be in the root of the mod directory
func get_required_mod_file_path(required_file: RequiredModFiles) -> String:
match required_file:
RequiredModFiles.MOD_MAIN:
return dir_path.path_join(MOD_MAIN)
RequiredModFiles.MANIFEST:
return dir_path.path_join(MANIFEST)
return ""
func get_optional_mod_file_path(optional_file: OptionalModFiles) -> String:
match optional_file:
OptionalModFiles.OVERWRITES:
return dir_path.path_join(OVERWRITES)
return ""
func get_mod_source() -> Sources:
if zip_path.contains("workshop"):
return Sources.STEAM_WORKSHOP
if zip_path == "":
return Sources.UNPACKED
return Sources.LOCAL

View File

@@ -0,0 +1 @@
uid://bfnhjikkx0g5s

View File

@@ -0,0 +1,536 @@
class_name ModManifest
extends Resource
##
## Stores and validates contents of the manifest set by the user
const LOG_NAME := "ModLoader:ModManifest"
# Validated by [method is_name_or_namespace_valid]
## Mod name.
var name := ""
# Validated by [method is_name_or_namespace_valid]
## Mod namespace, most commonly the main author.
var mod_namespace := ""
# Validated by [method is_semver_valid]
## Semantic version. Not a number, but required to be named like this by Thunderstore
var version_number := "0.0.0"
var description := ""
var website_url := ""
## Used to determine mod load order
var dependencies: PackedStringArray = []
## Used to determine mod load order
var optional_dependencies: PackedStringArray = []
## only used for information
var authors: PackedStringArray = []
## only used for information
var compatible_game_version: PackedStringArray = []
# Validated by [method _handle_compatible_mod_loader_version]
## only used for information
var compatible_mod_loader_version: PackedStringArray = []
## only used for information
var incompatibilities: PackedStringArray = []
## Used to determine mod load order
var load_before: PackedStringArray = []
## only used for information
var tags : PackedStringArray = []
## Schema for mod configs
var config_schema := {}
var description_rich := ""
var image: CompressedTexture2D
## only used for information
var steam_workshop_id := ""
var validation_messages_error : Array[String] = []
var validation_messages_warning : Array[String] = []
var is_valid := false
var has_parsing_failed := false
# Required keys in a mod's manifest.json file
const REQUIRED_MANIFEST_KEYS_ROOT: Array[String] = [
"name",
"namespace",
"version_number",
"website_url",
"description",
"dependencies",
"extra",
]
# Required keys in manifest's `json.extra.godot`
const REQUIRED_MANIFEST_KEYS_EXTRA: Array[String] = [
"authors",
"compatible_mod_loader_version",
"compatible_game_version",
]
# Takes the manifest as [Dictionary] and validates everything.
# Will return null if something is invalid.
func _init(manifest: Dictionary, path: String) -> void:
if manifest.is_empty():
validation_messages_error.push_back("The manifest cannot be validated due to missing data, most likely because parsing the manifest.json file failed.")
has_parsing_failed = true
else:
is_valid = validate(manifest, path)
func validate(manifest: Dictionary, path: String) -> bool:
var missing_fields: Array[String] = []
missing_fields.append_array(ModLoaderUtils.get_missing_dict_fields(manifest, REQUIRED_MANIFEST_KEYS_ROOT))
missing_fields.append_array(ModLoaderUtils.get_missing_dict_fields(manifest.extra, ["godot"]))
missing_fields.append_array(ModLoaderUtils.get_missing_dict_fields(manifest.extra.godot, REQUIRED_MANIFEST_KEYS_EXTRA))
if not missing_fields.is_empty():
validation_messages_error.push_back("Manifest is missing required fields: %s" % str(missing_fields))
name = manifest.name
mod_namespace = manifest.namespace
version_number = manifest.version_number
is_name_or_namespace_valid(name)
is_name_or_namespace_valid(mod_namespace)
var mod_id = get_mod_id()
is_semver_valid(mod_id, version_number, "version_number")
description = manifest.description
website_url = manifest.website_url
dependencies = manifest.dependencies
var godot_details: Dictionary = manifest.extra.godot
authors = ModLoaderUtils.get_array_from_dict(godot_details, "authors")
optional_dependencies = ModLoaderUtils.get_array_from_dict(godot_details, "optional_dependencies")
incompatibilities = ModLoaderUtils.get_array_from_dict(godot_details, "incompatibilities")
load_before = ModLoaderUtils.get_array_from_dict(godot_details, "load_before")
compatible_game_version = ModLoaderUtils.get_array_from_dict(godot_details, "compatible_game_version")
compatible_mod_loader_version = _handle_compatible_mod_loader_version(mod_id, godot_details)
description_rich = ModLoaderUtils.get_string_from_dict(godot_details, "description_rich")
tags = ModLoaderUtils.get_array_from_dict(godot_details, "tags")
config_schema = ModLoaderUtils.get_dict_from_dict(godot_details, "config_schema")
steam_workshop_id = ModLoaderUtils.get_string_from_dict(godot_details, "steam_workshop_id")
if ModLoaderStore.ml_options.game_version_validation == ModLoaderOptionsProfile.VERSION_VALIDATION.DEFAULT:
_is_game_version_compatible(mod_id)
if ModLoaderStore.ml_options.game_version_validation == ModLoaderOptionsProfile.VERSION_VALIDATION.CUSTOM:
if ModLoaderStore.ml_options.custom_game_version_validation_callable:
ModLoaderStore.ml_options.custom_game_version_validation_callable.call(self)
else:
ModLoaderLog.error("No custom game version validation callable detected. Please provide a valid validation callable.", LOG_NAME)
is_mod_id_array_valid(mod_id, dependencies, "dependency")
is_mod_id_array_valid(mod_id, incompatibilities, "incompatibility")
is_mod_id_array_valid(mod_id, optional_dependencies, "optional_dependency")
is_mod_id_array_valid(mod_id, load_before, "load_before")
validate_distinct_mod_ids_in_arrays(mod_id, dependencies, incompatibilities, ["dependencies", "incompatibilities"])
validate_distinct_mod_ids_in_arrays(mod_id, optional_dependencies, dependencies, ["optional_dependencies", "dependencies"])
validate_distinct_mod_ids_in_arrays(mod_id, optional_dependencies, incompatibilities, ["optional_dependencies", "incompatibilities"])
validate_distinct_mod_ids_in_arrays(
mod_id,
load_before,
dependencies,
["load_before", "dependencies"],
"\"load_before\" should be handled as optional dependency adding it to \"dependencies\" will cancel out the desired effect."
)
validate_distinct_mod_ids_in_arrays(
mod_id,
load_before,
optional_dependencies,
["load_before", "optional_dependencies"],
"\"load_before\" can be viewed as optional dependency, please remove the duplicate mod-id."
)
validate_distinct_mod_ids_in_arrays(mod_id,load_before,incompatibilities,["load_before", "incompatibilities"])
_validate_workshop_id(path)
return validation_messages_error.is_empty()
# Mod ID used in the mod loader
# Format: {namespace}-{name}
func get_mod_id() -> String:
return "%s-%s" % [mod_namespace, name]
# Package ID used by Thunderstore
# Format: {namespace}-{name}-{version_number}
func get_package_id() -> String:
return "%s-%s-%s" % [mod_namespace, name, version_number]
# Returns the Manifest values as a dictionary
func get_as_dict() -> Dictionary:
return {
"name": name,
"namespace": mod_namespace,
"version_number": version_number,
"description": description,
"website_url": website_url,
"dependencies": dependencies,
"optional_dependencies": optional_dependencies,
"authors": authors,
"compatible_game_version": compatible_game_version,
"compatible_mod_loader_version": compatible_mod_loader_version,
"incompatibilities": incompatibilities,
"load_before": load_before,
"tags": tags,
"config_schema": config_schema,
"description_rich": description_rich,
"image": image,
}
# Returns the Manifest values as JSON, in the manifest.json format
func to_json() -> String:
return JSON.stringify({
"name": name,
"namespace": mod_namespace,
"version_number": version_number,
"description": description,
"website_url": website_url,
"dependencies": dependencies,
"extra": {
"godot":{
"authors": authors,
"optional_dependencies": optional_dependencies,
"compatible_game_version": compatible_game_version,
"compatible_mod_loader_version": compatible_mod_loader_version,
"incompatibilities": incompatibilities,
"load_before": load_before,
"tags": tags,
"config_schema": config_schema,
"description_rich": description_rich,
"image": image,
}
}
}, "\t")
# Loads the default configuration for a mod.
func load_mod_config_defaults() -> ModConfig:
var default_config_save_path := _ModLoaderPath.get_path_to_mod_config_file(get_mod_id(), ModLoaderConfig.DEFAULT_CONFIG_NAME)
var config := ModConfig.new(
get_mod_id(),
{},
default_config_save_path,
config_schema
)
# Check if there is no default.json file in the mods config directory
if not _ModLoaderFile.file_exists(config.save_path):
# Generate config_default based on the default values in config_schema
config.data = _generate_default_config_from_schema(config.schema.properties)
# If the default.json file exists
else:
var current_schema_md5 := config.get_schema_as_string().md5_text()
var cache_schema_md5s := _ModLoaderCache.get_data("config_schemas")
var cache_schema_md5: String = cache_schema_md5s[config.mod_id] if cache_schema_md5s.has(config.mod_id) else ''
# Generate a new default config if the config schema has changed or there is nothing cached
if not current_schema_md5 == cache_schema_md5 or cache_schema_md5.is_empty():
config.data = _generate_default_config_from_schema(config.schema.properties)
# If the config schema has not changed just load the json file
else:
config.data = _ModLoaderFile.get_json_as_dict(config.save_path)
# Validate the config defaults
if config.is_valid():
# Create the default config file
config.save_to_file()
# Store the md5 of the config schema in the cache
_ModLoaderCache.update_data("config_schemas", {config.mod_id: config.get_schema_as_string().md5_text()} )
# Return the default ModConfig
return config
ModLoaderLog.fatal("The default config values for %s-%s are invalid. Configs will not be loaded." % [mod_namespace, name], LOG_NAME)
return null
# Recursively searches for default values
func _generate_default_config_from_schema(property: Dictionary, current_prop := {}) -> Dictionary:
# Exit function if property is empty
if property.is_empty():
return current_prop
for property_key in property.keys():
var prop = property[property_key]
# If this property contains nested properties, we recursively call this function
if "properties" in prop:
current_prop[property_key] = {}
_generate_default_config_from_schema(prop.properties, current_prop[property_key])
# Return early here because a object will not have a "default" key
return current_prop
# If this property contains a default value, add it to the global config_defaults dictionary
if JSONSchema.JSKW_DEFAULT in prop:
# Initialize the current_key if it is missing in config_defaults
if not current_prop.has(property_key):
current_prop[property_key] = {}
# Add the default value to the config_defaults
current_prop[property_key] = prop.default
return current_prop
# Handles deprecation of the single string value in the compatible_mod_loader_version.
func _handle_compatible_mod_loader_version(mod_id: String, godot_details: Dictionary) -> Array:
var link_manifest_docs := "https://github.com/GodotModding/godot-mod-loader/wiki/Mod-Files#manifestjson"
var array_value := ModLoaderUtils.get_array_from_dict(godot_details, "compatible_mod_loader_version")
# If there are array values
if array_value.size() > 0:
# Check for valid versions
if not is_semver_version_array_valid(mod_id, array_value, "compatible_mod_loader_version"):
return []
return array_value
# If the array is empty check if a string was passed
var string_value := ModLoaderUtils.get_string_from_dict(godot_details, "compatible_mod_loader_version")
# If an empty string was passed
if string_value == "":
# Using str() here because format strings caused an error
validation_messages_error.push_back(
str (
"%s - \"compatible_mod_loader_version\" is a required field." +
" For more details visit %s"
) % [mod_id, link_manifest_docs])
return []
return [string_value]
# A valid namespace may only use letters (any case), numbers and underscores
# and has to be longer than 3 characters
# a-z A-Z 0-9 _ (longer than 3 characters)
func is_name_or_namespace_valid(check_name: String, is_silent := false) -> bool:
var re := RegEx.new()
var _compile_error_1 = re.compile("^[a-zA-Z0-9_]*$") # alphanumeric and _
if re.search(check_name) == null:
if not is_silent:
validation_messages_error.push_back("Invalid name or namespace: \"%s\". You may only use letters, numbers and underscores." % check_name)
return false
var _compile_error_2 = re.compile("^[a-zA-Z0-9_]{3,}$") # at least 3 long
if re.search(check_name) == null:
if not is_silent:
validation_messages_error.push_back("Invalid name or namespace: \"%s\". Must be longer than 3 characters." % check_name)
return false
return true
func is_semver_version_array_valid(mod_id: String, version_array: PackedStringArray, version_array_descripton: String, is_silent := false) -> bool:
var is_valid := true
for version in version_array:
if not is_semver_valid(mod_id, version, version_array_descripton, is_silent):
is_valid = false
return is_valid
# A valid semantic version should follow this format: {mayor}.{minor}.{patch}
# reference https://semver.org/ for details
# {0-9}.{0-9}.{0-9} (no leading 0, shorter than 16 characters total)
func is_semver_valid(mod_id: String, check_version_number: String, field_name: String, is_silent := false) -> bool:
var re := RegEx.new()
var _compile_error = re.compile("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")
if re.search(check_version_number) == null:
if not is_silent:
# Using str() here because format strings caused an error
validation_messages_error.push_back(
str(
"Invalid semantic version: \"%s\" in field \"%s\" of mod \"%s\". " +
"You may only use numbers without leading zero and periods " +
"following this format {mayor}.{minor}.{patch}"
) % [check_version_number, field_name, mod_id]
)
return false
if check_version_number.length() > 16:
if not is_silent:
validation_messages_error.push_back(
str(
"Invalid semantic version: \"%s\" in field \"%s\" of mod \"%s\". " +
"Version number must be shorter than 16 characters."
) % [check_version_number, field_name, mod_id]
)
return false
return true
func validate_distinct_mod_ids_in_arrays(
mod_id: String,
array_one: PackedStringArray,
array_two: PackedStringArray,
array_description: PackedStringArray,
additional_info := "",
is_silent := false
) -> bool:
# Initialize an empty array to hold any overlaps.
var overlaps: PackedStringArray = []
# Loop through each incompatibility and check if it is also listed as a dependency.
for loop_mod_id in array_one:
if array_two.has(loop_mod_id):
overlaps.push_back(loop_mod_id)
# If no overlaps were found
if overlaps.size() == 0:
return true
# If any overlaps were found
if not is_silent:
validation_messages_error.push_back(
(
"The mod -> %s lists the same mod(s) -> %s - in \"%s\" and \"%s\". %s"
% [mod_id, overlaps, array_description[0], array_description[1], additional_info]
)
)
return false
# If silent just return false
return false
func is_mod_id_array_valid(own_mod_id: String, mod_id_array: PackedStringArray, mod_id_array_description: String, is_silent := false) -> bool:
var is_valid := true
# If there are mod ids
if mod_id_array.size() > 0:
for mod_id in mod_id_array:
# Check if mod id is the same as the mods mod id.
if mod_id == own_mod_id:
is_valid = false
if not is_silent:
validation_messages_error.push_back("The mod \"%s\" lists itself as \"%s\" in its own manifest.json file" % [mod_id, mod_id_array_description])
# Check if the mod id is a valid mod id.
if not is_mod_id_valid(own_mod_id, mod_id, mod_id_array_description, is_silent):
is_valid = false
return is_valid
func is_mod_id_valid(original_mod_id: String, check_mod_id: String, type := "", is_silent := false) -> bool:
var intro_text = "A %s for the mod \"%s\" is invalid: " % [type, original_mod_id] if not type == "" else ""
# contains hyphen?
if not check_mod_id.count("-") == 1:
if not is_silent:
validation_messages_error.push_back(str(intro_text, "Expected a single hyphen in the mod ID, but the %s was: \"%s\"" % [type, check_mod_id]))
return false
# at least 7 long (1 for hyphen, 3 each for namespace/name)
var mod_id_length = check_mod_id.length()
if mod_id_length < 7:
if not is_silent:
validation_messages_error.push_back(str(intro_text, "Mod ID for \"%s\" is too short. It must be at least 7 characters long, but its length is: %s" % [check_mod_id, mod_id_length]))
return false
var split = check_mod_id.split("-")
var check_namespace = split[0]
var check_name = split[1]
var re := RegEx.new()
re.compile("^[a-zA-Z0-9_]{3,}$") # alphanumeric and _ and at least 3 characters
if re.search(check_namespace) == null:
if not is_silent:
validation_messages_error.push_back(str(intro_text, "Mod ID has an invalid namespace (author) for \"%s\". Namespace can only use letters, numbers and underscores, but was: \"%s\"" % [check_mod_id, check_namespace]))
return false
if re.search(check_name) == null:
if not is_silent:
validation_messages_error.push_back(str(intro_text, "Mod ID has an invalid name for \"%s\". Name can only use letters, numbers and underscores, but was: \"%s\"" % [check_mod_id, check_name]))
return false
return true
func is_string_length_valid(mod_id: String, field: String, string: String, required_length: int, is_silent := false) -> bool:
if not string.length() == required_length:
if not is_silent:
validation_messages_error.push_back("Invalid length in field \"%s\" of mod \"%s\" it should be \"%s\" but it is \"%s\"." % [field, mod_id, required_length, string.length()])
return false
return true
# Validates the workshop id separately from the rest since it needs the ModData
func _validate_workshop_id(path: String) -> void:
var steam_workshop_id_from_path := _ModLoaderPath.get_steam_workshop_id(path)
var is_mod_source_workshop := not steam_workshop_id_from_path.is_empty()
if not _is_steam_workshop_id_valid(get_mod_id(), steam_workshop_id_from_path, steam_workshop_id, is_mod_source_workshop):
# Override the invalid steam_workshop_id if we load from the workshop
if is_mod_source_workshop:
steam_workshop_id = steam_workshop_id_from_path
func _is_steam_workshop_id_valid(mod_id: String, steam_workshop_id_from_path: String, steam_workshop_id_to_validate: String, is_mod_source_workshop := false, is_silent := false) -> bool:
if steam_workshop_id_to_validate.is_empty():
# Workshop id is optional, so we return true if no id is given
return true
# Validate the steam_workshop_id based on the zip_path if the mod is loaded from the workshop
if is_mod_source_workshop:
if not steam_workshop_id_to_validate == steam_workshop_id_from_path:
if not is_silent:
ModLoaderLog.warning("The \"steam_workshop_id\": \"%s\" provided by the mod manifest of mod \"%s\" is incorrect, it should be \"%s\"." % [steam_workshop_id_to_validate, mod_id, steam_workshop_id_from_path], LOG_NAME)
return false
else:
if not is_string_length_valid(mod_id, "steam_workshop_id", steam_workshop_id_to_validate, 10, is_silent):
# Invalidate the manifest in this case because the mod is most likely in development if it is not loaded from the steam workshop.
return false
return true
func _is_game_version_compatible(mod_id: String) -> bool:
var game_version: String = ModLoaderStore.ml_options.semantic_version
var game_major := int(game_version.get_slice(".", 0))
var game_minor := int(game_version.get_slice(".", 1))
var valid_major := false
var valid_minor := false
for version in compatible_game_version:
var compat_major := int(version.get_slice(".", 0))
var compat_minor := int(version.get_slice(".", 1))
if compat_major < game_major:
continue
valid_major = true
if compat_minor < game_minor:
continue
valid_minor = true
if not valid_major:
validation_messages_error.push_back(
"The mod \"%s\" is incompatible with the current game version.
(current game version: %s, mod compatible with game versions: %s)" %
[mod_id, game_version, compatible_game_version]
)
return false
if not valid_minor:
validation_messages_warning.push_back(
"The mod \"%s\" may not be compatible with the current game version.
Enable at your own risk. (current game version: %s, mod compatible with game versions: %s)" %
[mod_id, game_version, compatible_game_version]
)
return true
return true

View File

@@ -0,0 +1 @@
uid://bleh3oamdbmnr

View File

@@ -0,0 +1,22 @@
class_name ModUserProfile
extends Resource
## This Class is used to represent a User Profile for the ModLoader.
## The name of the profile
var name := ""
## A list of all installed mods
## [codeblock]
## "mod_list": {
## "Namespace-ModName": {
## "current_config": "default",
## "is_active": false,
## "zip_path": "",
## },
## [/codeblock]
var mod_list := {}
func _init(_name := "", _mod_list := {}) -> void:
name = _name
mod_list = _mod_list

View File

@@ -0,0 +1 @@
uid://ddrlbkscua6n0

View File

@@ -0,0 +1,15 @@
class_name ModLoaderCurrentOptions
extends Resource
# The default options set for the mod loader
@export var current_options: Resource = preload(
"res://addons/mod_loader/options/profiles/default.tres"
)
# Overrides for all available feature tags through OS.has_feature()
# Format: Dictionary[String: ModLoaderOptionsProfile] where the string is a tag
# Warning: Some tags can occur at the same time (Windows + editor for example) -
# In a case where multiple apply, the last one in the dict will override all others
@export var feature_override_options: Dictionary = {
"editor": preload("res://addons/mod_loader/options/profiles/editor.tres")
}

View File

@@ -0,0 +1 @@
uid://cmxtu4snlj1bb

View File

@@ -0,0 +1,119 @@
class_name ModLoaderOptionsProfile
extends Resource
##
## Class to define and store Mod Loader Options.
##
## @tutorial(Example Customization Script): https://wiki.godotmodding.com/guides/integration/mod_loader_options/#game-version-validation
## Settings for game version validation.
enum VERSION_VALIDATION {
## Uses the default semantic versioning (semver) validation.
DEFAULT,
## Disables validation of the game version specified in [member semantic_version]
## and the mod's [member ModManifest.compatible_game_version].
DISABLED,
## Enables custom game version validation.
## Use [member customize_script_path] to specify a script that customizes the Mod Loader options.
## In this script, you must set [member custom_game_version_validation_callable]
## to a custom validation [Callable].
## [br]
## ===[br]
## [b]Note:[color=note "Easier Mod Loader Updates"][/color][/b][br]
## Using a custom script allows you to keep your code outside the addons directory,
## making it easier to update the mod loader without affecting your modifications. [br]
## ===[br]
CUSTOM,
}
## Can be used to disable mods for specific plaforms by using feature overrides
@export var enable_mods: bool = true
## List of mod ids that can't be turned on or off
@export var locked_mods: Array[String] = []
## List of mods that will not be loaded
@export var disabled_mods: Array[String] = []
## Disables the requirement for the mod loader autoloads to be first
@export var allow_modloader_autoloads_anywhere: bool = false
## This script is loaded after [member ModLoaderStore.ml_options] has been initialized.
## It is instantiated with [member ModLoaderStore.ml_options] as an argument.
## Use this script to apply settings that cannot be configured through the editor UI.
##
## For an example, see [enum VERSION_VALIDATION] [code]CUSTOM[/code] or
## [code]res://addons/mod_loader/options/example_customize_script.gd[/code].
@export_file var customize_script_path: String
@export_group("Logging")
## Sets the logging verbosity level.
## Refer to [enum ModLoaderLog.VERBOSITY_LEVEL] for more details.
@export var log_level := ModLoaderLog.VERBOSITY_LEVEL.DEBUG
## Stops the mod loader from logging any deprecation related errors.
@export var ignore_deprecated_errors: bool = false
## Ignore messages from these namespaces.[br]
## Accepts * as wildcard. [br]
## [code]ModLoader:Dependency[/code] - ignore the exact name [br]
## [code]ModLoader:*[/code] - ignore all beginning with this name [br]
@export var ignored_mod_names_in_log: Array[String] = []
@export var hint_color := Color("#70bafa")
@export_group("Game Data")
## Steam app id, can be found in the steam page url
@export var steam_id: int = 0:
get:
return steam_id
## Semantic game version. [br]
## Replace the getter in options_profile.gd if your game stores the version somewhere else
@export var semantic_version := "0.0.0":
get:
return semantic_version
@export_group("Mod Sources")
## Indicates whether to load mods from the Steam Workshop directory, or the overridden workshop path.
@export var load_from_steam_workshop: bool = false
## Indicates whether to load mods from the "mods" folder located at the game's install directory, or the overridden mods path.
@export var load_from_local: bool = true
## Indicates whether to load mods from [code]"res://mods-unpacked"[/code] in the exported game.[br]
## ===[br]
## [b]Note:[color=note "Load from unpacked in the editor"][/color][/b][br]
## In the editor, mods inside [code]"res://mods-unpacked"[/code] are always loaded. Use [member enable_mods] to disable mod loading completely.[br]
## ===[br]
@export var load_from_unpacked: bool = true
## Path to a folder containing mods [br]
## Mod zips should be directly in this folder
@export_dir var override_path_to_mods = ""
## Use this option to override the default path where configs are stored.
@export_dir var override_path_to_configs = ""
## Path to a folder containing workshop items.[br]
## Mods zips are placed in another folder, usually[br]
## [code]/<workshop id>/mod.zip[/code][br]
## The real workshop path ends with [br]
## [code]/workshop/content[/code] [br]
@export_dir var override_path_to_workshop = ""
@export_group("Mod Hooks")
## 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.
@export_global_dir var override_path_to_hook_pack := ""
## Can be used to override the default hook pack name, by default it is [constant ModLoaderStore.MOD_HOOK_PACK_NAME]
@export var override_hook_pack_name := ""
## Can be used to specify your own scene that is displayed if a game restart is required.
## For example if new mod hooks were generated.
@export_dir var restart_notification_scene_path := "res://addons/mod_loader/restart_notification.tscn"
## Can be used to disable the mod loader's restart logic. Use the [signal ModLoader.new_hooks_created] to implement your own restart logic.
@export var disable_restart := false
@export_group("Mod Validation")
## Defines how the game version should be validated.
## This setting controls validation for the game version specified in [member semantic_version]
## and the mod's [member ModManifest.compatible_game_version].
@export var game_version_validation := VERSION_VALIDATION.DEFAULT
## Callable that is executed during [ModManifest] validation
## if [member game_version_validation] is set to [enum VERSION_VALIDATION] [code]CUSTOM[/code].
## See the example under [enum VERSION_VALIDATION] [code]CUSTOM[/code] to learn how to set this up.
var custom_game_version_validation_callable: Callable
## Stores the instance of the script specified in [member customize_script_path].
var customize_script_instance: RefCounted

View File

@@ -0,0 +1 @@
uid://dsbicisgihjet

View File

@@ -0,0 +1,34 @@
extends Control
@export var wait_time := 20.0
@onready var timer_label: Label = %TimerLabel
@onready var timer: Timer = %Timer
@onready var restart_button: Button = %RestartButton
@onready var cancel_button: Button = %CancelButton
func _ready() -> void:
cancel_button.pressed.connect(cancel)
restart_button.pressed.connect(restart)
restart_button.grab_focus()
timer.timeout.connect(restart)
timer.start(wait_time)
func _process(delta: float) -> void:
timer_label.text = "%d" % (timer.time_left -1)
func cancel() -> void:
timer.stop()
hide()
queue_free()
func restart() -> void:
OS.set_restart_on_exit(true)
get_tree().quit()

View File

@@ -0,0 +1 @@
uid://k10oyyxy00y1

View File

@@ -0,0 +1,95 @@
[gd_scene load_steps=4 format=3 uid="uid://cb85yktpgxq7n"]
[ext_resource type="Script" path="res://addons/mod_loader/restart_notification.gd" id="1_mxg68"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2apd5"]
bg_color = Color(0.253906, 0.253906, 0.253906, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="LabelSettings" id="LabelSettings_mmnnw"]
font_size = 20
[node name="CanvasLayer" type="CanvasLayer"]
layer = 99
[node name="ModLoaderNewHooks" type="MarginContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_mxg68")
[node name="CenterContainer" type="CenterContainer" parent="ModLoaderNewHooks"]
layout_mode = 2
[node name="PanelContainer" type="PanelContainer" parent="ModLoaderNewHooks/CenterContainer"]
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_2apd5")
[node name="MarginContainer" type="MarginContainer" parent="ModLoaderNewHooks/CenterContainer/PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 20
theme_override_constants/margin_bottom = 20
[node name="VBoxContainer" type="BoxContainer" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 20
vertical = true
[node name="Label" type="Label" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
text = "New mods will be applied after a restart."
label_settings = SubResource("LabelSettings_mmnnw")
horizontal_alignment = 1
[node name="BoxContainer2" type="BoxContainer" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
alignment = 1
[node name="Label" type="Label" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/BoxContainer2"]
layout_mode = 2
text = "Restarting in: "
horizontal_alignment = 1
[node name="TimerLabel" type="Label" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/BoxContainer2"]
unique_name_in_owner = true
custom_minimum_size = Vector2(50, 0)
layout_mode = 2
text = "20"
horizontal_alignment = 1
[node name="Label2" type="Label" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/BoxContainer2"]
layout_mode = 2
text = "sec"
horizontal_alignment = 1
[node name="BoxContainer" type="BoxContainer" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
alignment = 1
[node name="CancelButton" type="Button" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/BoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Cancel Restart"
[node name="Spacer" type="Control" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/BoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
[node name="RestartButton" type="Button" parent="ModLoaderNewHooks/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/BoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Restart Now"
[node name="Timer" type="Timer" parent="ModLoaderNewHooks"]
unique_name_in_owner = true

View File

@@ -0,0 +1,163 @@
list=Array[Dictionary]([{
"base": &"RefCounted",
"class": &"JSONSchema",
"icon": "",
"language": &"GDScript",
"path": "res://addons/JSON_Schema_Validator/json_schema_validator.gd"
}, {
"base": &"Resource",
"class": &"ModConfig",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/resources/mod_config.gd"
}, {
"base": &"Resource",
"class": &"ModData",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/resources/mod_data.gd"
}, {
"base": &"Object",
"class": &"ModLoaderConfig",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/api/config.gd"
}, {
"base": &"Resource",
"class": &"ModLoaderCurrentOptions",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/resources/options_current.gd"
}, {
"base": &"Object",
"class": &"ModLoaderDeprecated",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/api/deprecated.gd"
}, {
"base": &"RefCounted",
"class": &"ModLoaderHookChain",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/api/hook_chain.gd"
}, {
"base": &"Object",
"class": &"ModLoaderLog",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/api/log.gd"
}, {
"base": &"Object",
"class": &"ModLoaderMod",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/api/mod.gd"
}, {
"base": &"Resource",
"class": &"ModLoaderOptionsProfile",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/resources/options_profile.gd"
}, {
"base": &"Object",
"class": &"ModLoaderUserProfile",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/api/profile.gd"
}, {
"base": &"Node",
"class": &"ModLoaderUtils",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/mod_loader_utils.gd"
}, {
"base": &"Resource",
"class": &"ModManifest",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/resources/mod_manifest.gd"
}, {
"base": &"Resource",
"class": &"ModUserProfile",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/resources/mod_user_profile.gd"
}, {
"base": &"Node",
"class": &"Utilities",
"icon": "",
"language": &"GDScript",
"path": "res://tools/utilities.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderCLI",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/cli.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderCache",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/cache.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderDependency",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/dependency.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderFile",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/file.gd"
}, {
"base": &"Object",
"class": &"_ModLoaderGodot",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/godot.gd"
}, {
"base": &"Object",
"class": &"_ModLoaderHooks",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/hooks.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderModHookPacker",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/mod_hook_packer.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderModHookPreProcessor",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/mod_hook_preprocessor.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderPath",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/path.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderSceneExtension",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/scene_extension.gd"
}, {
"base": &"RefCounted",
"class": &"_ModLoaderScriptExtension",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/script_extension.gd"
}, {
"base": &"Node",
"class": &"_ModLoaderSteam",
"icon": "",
"language": &"GDScript",
"path": "res://addons/mod_loader/internal/third_party/steam.gd"
}])

View File

@@ -0,0 +1,209 @@
class_name ModLoaderSetupLog
# Slimed down version of ModLoaderLog for the ModLoader Self Setup
const MOD_LOG_PATH := "user://logs/modloader.log"
enum VERBOSITY_LEVEL {
ERROR,
WARNING,
INFO,
DEBUG,
}
class ModLoaderLogEntry:
extends Resource
var mod_name: String
var message: String
var type: String
var time: String
func _init(_mod_name: String, _message: String, _type: String, _time: String) -> void:
mod_name = _mod_name
message = _message
type = _type
time = _time
func get_entry() -> String:
return time + get_prefix() + message
func get_prefix() -> String:
return "%s %s: " % [type.to_upper(), mod_name]
func get_md5() -> String:
return str(get_prefix(), message).md5_text()
# API log functions
# =============================================================================
# Logs the error in red and a stack trace. Prefixed FATAL-ERROR
# Stops the execution in editor
# Always logged
static func fatal(message: String, mod_name: String) -> void:
_log(message, mod_name, "fatal-error")
# Logs the message and pushed an error. Prefixed ERROR
# Always logged
static func error(message: String, mod_name: String) -> void:
_log(message, mod_name, "error")
# Logs the message and pushes a warning. Prefixed WARNING
# Logged with verbosity level at or above warning (-v)
static func warning(message: String, mod_name: String) -> void:
_log(message, mod_name, "warning")
# Logs the message. Prefixed INFO
# Logged with verbosity level at or above info (-vv)
static func info(message: String, mod_name: String) -> void:
_log(message, mod_name, "info")
# Logs the message. Prefixed SUCCESS
# Logged with verbosity level at or above info (-vv)
static func success(message: String, mod_name: String) -> void:
_log(message, mod_name, "success")
# Logs the message. Prefixed DEBUG
# Logged with verbosity level at or above debug (-vvv)
static func debug(message: String, mod_name: String) -> void:
_log(message, mod_name, "debug")
# Logs the message formatted with [method JSON.print]. Prefixed DEBUG
# Logged with verbosity level at or above debug (-vvv)
static func debug_json_print(message: String, json_printable, mod_name: String) -> void:
message = "%s\n%s" % [message, JSON.stringify(json_printable, " ")]
_log(message, mod_name, "debug")
# Internal log functions
# =============================================================================
static func _log(message: String, mod_name: String, log_type: String = "info") -> void:
var time := "%s " % _get_time_string()
var log_entry := ModLoaderLogEntry.new(mod_name, message, log_type, time)
match log_type.to_lower():
"fatal-error":
push_error(message)
_write_to_log_file(log_entry.get_entry())
_write_to_log_file(JSON.stringify(get_stack(), " "))
assert(false, message)
"error":
printerr(message)
push_error(message)
_write_to_log_file(log_entry.get_entry())
"warning":
print(log_entry.get_prefix() + message)
push_warning(message)
_write_to_log_file(log_entry.get_entry())
"info", "success":
print(log_entry.get_prefix() + message)
_write_to_log_file(log_entry.get_entry())
"debug":
print(log_entry.get_prefix() + message)
_write_to_log_file(log_entry.get_entry())
# Internal Date Time
# =============================================================================
# Returns the current time as a string in the format hh:mm:ss
static func _get_time_string() -> String:
var date_time := Time.get_datetime_dict_from_system()
return "%02d:%02d:%02d" % [ date_time.hour, date_time.minute, date_time.second ]
# Returns the current date as a string in the format yyyy-mm-dd
static func _get_date_string() -> String:
var date_time := Time.get_datetime_dict_from_system()
return "%s-%02d-%02d" % [ date_time.year, date_time.month, date_time.day ]
# Returns the current date and time as a string in the format yyyy-mm-dd_hh:mm:ss
static func _get_date_time_string() -> String:
return "%s_%s" % [ _get_date_string(), _get_time_string() ]
# Internal File
# =============================================================================
static func _write_to_log_file(string_to_write: String) -> void:
if not FileAccess.file_exists(MOD_LOG_PATH):
_rotate_log_file()
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.READ_WRITE)
if log_file == null:
assert(false, "Could not open log file, error code: %s" % error)
return
log_file.seek_end()
log_file.store_string("\n" + string_to_write)
log_file.close()
# Keeps log backups for every run, just like the Godot; gdscript implementation of
# https://github.com/godotengine/godot/blob/1d14c054a12dacdc193b589e4afb0ef319ee2aae/core/io/logger.cpp#L151
static func _rotate_log_file() -> void:
var MAX_LOGS: int = ProjectSettings.get_setting("debug/file_logging/max_log_files")
if FileAccess.file_exists(MOD_LOG_PATH):
if MAX_LOGS > 1:
var datetime := _get_date_time_string().replace(":", ".")
var backup_name: String = MOD_LOG_PATH.get_basename() + "_" + datetime
if MOD_LOG_PATH.get_extension().length() > 0:
backup_name += "." + MOD_LOG_PATH.get_extension()
var dir := DirAccess.open(MOD_LOG_PATH.get_base_dir())
if not dir == null:
dir.copy(MOD_LOG_PATH, backup_name)
_clear_old_log_backups()
# only File.WRITE creates a new file, File.READ_WRITE throws an error
var log_file := FileAccess.open(MOD_LOG_PATH, FileAccess.WRITE)
if log_file == null:
assert(false, "Could not open log file, error code: %s" % error)
log_file.store_string('%s Created log' % _get_date_string())
log_file.close()
static func _clear_old_log_backups() -> void:
var MAX_LOGS := int(ProjectSettings.get_setting("debug/file_logging/max_log_files"))
var MAX_BACKUPS := MAX_LOGS - 1 # -1 for the current new log (not a backup)
var basename := MOD_LOG_PATH.get_file().get_basename() as String
var extension := MOD_LOG_PATH.get_extension() as String
var dir := DirAccess.open(MOD_LOG_PATH.get_base_dir())
if dir == null:
return
dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
var file := dir.get_next()
var backups := []
while file.length() > 0:
if (not dir.current_is_dir() and
file.begins_with(basename) and
file.get_extension() == extension and
not file == MOD_LOG_PATH.get_file()):
backups.append(file)
file = dir.get_next()
dir.list_dir_end()
if backups.size() > MAX_BACKUPS:
backups.sort()
backups.resize(backups.size() - MAX_BACKUPS)
for file_to_delete in backups:
dir.remove(file_to_delete)

View File

@@ -0,0 +1 @@
uid://djchjoj06bcko

View File

@@ -0,0 +1,286 @@
class_name ModLoaderSetupUtils
# Slimed down version of ModLoaderUtils for the ModLoader Self Setup
const LOG_NAME := "ModLoader:SetupUtils"
static var ModLoaderSetupLog: Object = load("res://addons/mod_loader/setup/setup_log.gd")
# Get the path to a local folder. Primarily used to get the (packed) mods
# folder, ie "res://mods" or the OS's equivalent, as well as the configs path
static func get_local_folder_dir(subfolder: String = "") -> String:
var game_install_directory := OS.get_executable_path().get_base_dir()
if OS.get_name() == "macOS":
game_install_directory = game_install_directory.get_base_dir().get_base_dir()
# Fix for running the game through the Godot editor (as the EXE path would be
# the editor's own EXE, which won't have any mod ZIPs)
# if OS.is_debug_build():
if OS.has_feature("editor"):
game_install_directory = "res://"
return game_install_directory.path_join(subfolder)
# Provide a path, get the file name at the end of the path
static func get_file_name_from_path(path: String, make_lower_case := true, remove_extension := false) -> String:
var file_name := path.get_file()
if make_lower_case:
file_name = file_name.to_lower()
if remove_extension:
file_name = file_name.trim_suffix("." + file_name.get_extension())
return file_name
# Get an array of all autoloads -> ["autoload/AutoloadName", ...]
static func get_autoload_array() -> Array:
var autoloads := []
# Get all autoload settings
for prop in ProjectSettings.get_property_list():
var name: String = prop.name
if name.begins_with("autoload/"):
autoloads.append(name.trim_prefix("autoload/"))
return autoloads
# Get the index of a specific autoload
static func get_autoload_index(autoload_name: String) -> int:
var autoloads := get_autoload_array()
var autoload_index := autoloads.find(autoload_name)
return autoload_index
# Get the path where override.cfg will be stored.
# Not the same as the local folder dir (for mac)
static func get_override_path() -> String:
var base_path := ""
if OS.has_feature("editor"):
base_path = ProjectSettings.globalize_path("res://")
else:
# this is technically different to res:// in macos, but we want the
# executable dir anyway, so it is exactly what we need
base_path = OS.get_executable_path().get_base_dir()
return base_path.path_join("override.cfg")
# Register an array of classes to the global scope, since Godot only does that in the editor.
static func register_global_classes_from_array(new_global_classes: Array) -> void:
var registered_classes: Array = ProjectSettings.get_setting("_global_script_classes")
var registered_class_icons: Dictionary = ProjectSettings.get_setting("_global_script_class_icons")
for new_class in new_global_classes:
if not _is_valid_global_class_dict(new_class):
continue
for old_class in registered_classes:
if old_class.class == new_class.class:
if OS.has_feature("editor"):
ModLoaderSetupLog.info('Class "%s" to be registered as global was already registered by the editor. Skipping.' % new_class.class, LOG_NAME)
else:
ModLoaderSetupLog.info('Class "%s" to be registered as global already exists. Skipping.' % new_class.class, LOG_NAME)
continue
registered_classes.append(new_class)
registered_class_icons[new_class.class] = "" # empty icon, does not matter
ProjectSettings.set_setting("_global_script_classes", registered_classes)
ProjectSettings.set_setting("_global_script_class_icons", registered_class_icons)
# Checks if all required fields are in the given [Dictionary]
# Format: { "base": "ParentClass", "class": "ClassName", "language": "GDScript", "path": "res://path/class_name.gd" }
static func _is_valid_global_class_dict(global_class_dict: Dictionary) -> bool:
var required_fields := ["base", "class", "language", "path"]
if not global_class_dict.has_all(required_fields):
ModLoaderSetupLog.fatal("Global class to be registered is missing one of %s" % required_fields, LOG_NAME)
return false
if not FileAccess.file_exists(global_class_dict.path):
ModLoaderSetupLog.fatal('Class "%s" to be registered as global could not be found at given path "%s"' %
[global_class_dict.class, global_class_dict.path], LOG_NAME)
return false
return true
# Check if the provided command line argument was present when launching the game
static func is_running_with_command_line_arg(argument: String) -> bool:
for arg in OS.get_cmdline_args():
if argument == arg.split("=")[0]:
return true
return false
# Get the command line argument value if present when launching the game
static func get_cmd_line_arg_value(argument: String) -> String:
var args := _get_fixed_cmdline_args()
for arg_index in args.size():
var arg := args[arg_index] as String
var key := arg.split("=")[0]
if key == argument:
# format: `--arg=value` or `--arg="value"`
if "=" in arg:
var value := arg.trim_prefix(argument + "=")
value = value.trim_prefix('"').trim_suffix('"')
value = value.trim_prefix("'").trim_suffix("'")
return value
# format: `--arg value` or `--arg "value"`
elif arg_index +1 < args.size() and not args[arg_index +1].begins_with("--"):
return args[arg_index + 1]
return ""
static func _get_fixed_cmdline_args() -> PackedStringArray:
return fix_godot_cmdline_args_string_space_splitting(OS.get_cmdline_args())
# Reverses a bug in Godot, which splits input strings at spaces even if they are quoted
# e.g. `--arg="some value" --arg-two 'more value'` becomes `[ --arg="some, value", --arg-two, 'more, value' ]`
static func fix_godot_cmdline_args_string_space_splitting(args: PackedStringArray) -> PackedStringArray:
if not OS.has_feature("editor"): # only happens in editor builds
return args
if OS.has_feature("windows"): # windows is unaffected
return args
var fixed_args := PackedStringArray([])
var fixed_arg := ""
# if we encounter an argument that contains `=` followed by a quote,
# or an argument that starts with a quote, take all following args and
# concatenate them into one, until we find the closing quote
for arg in args:
var arg_string := arg as String
if '="' in arg_string or '="' in fixed_arg or \
arg_string.begins_with('"') or fixed_arg.begins_with('"'):
if not fixed_arg == "":
fixed_arg += " "
fixed_arg += arg_string
if arg_string.ends_with('"'):
fixed_args.append(fixed_arg.trim_prefix(" "))
fixed_arg = ""
continue
# same thing for single quotes
elif "='" in arg_string or "='" in fixed_arg \
or arg_string.begins_with("'") or fixed_arg.begins_with("'"):
if not fixed_arg == "":
fixed_arg += " "
fixed_arg += arg_string
if arg_string.ends_with("'"):
fixed_args.append(fixed_arg.trim_prefix(" "))
fixed_arg = ""
continue
else:
fixed_args.append(arg_string)
return fixed_args
# Slightly modified version of:
# https://gist.github.com/willnationsdev/00d97aa8339138fd7ef0d6bd42748f6e
# Removed .import from the extension filter.
# p_match is a string that filters the list of files.
# If p_match_is_regex is false, p_match is directly string-searched against the FILENAME.
# If it is true, a regex object compiles p_match and runs it against the FILEPATH.
static func get_flat_view_dict(
p_dir := "res://",
p_match := "",
p_match_file_extensions: Array[StringName] = [],
p_match_is_regex := false,
include_empty_dirs := false,
ignored_dirs: Array[StringName] = []
) -> PackedStringArray:
var data: PackedStringArray = []
var regex: RegEx
if p_match_is_regex:
regex = RegEx.new()
var _compile_error: int = regex.compile(p_match)
if not regex.is_valid():
return data
var dirs := [p_dir]
var first := true
while not dirs.is_empty():
var dir_name : String = dirs.back()
var dir := DirAccess.open(dir_name)
dirs.pop_back()
if dir_name.lstrip("res://").get_slice("/", 0) in ignored_dirs:
continue
if dir:
var _dirlist_error: int = dir.list_dir_begin()
var file_name := dir.get_next()
if include_empty_dirs and not dir_name == p_dir:
data.append(dir_name)
while file_name != "":
if not dir_name == "res://":
first = false
# ignore hidden, temporary, or system content
if not file_name.begins_with(".") and not file_name.get_extension() == "tmp":
# If a directory, then add to list of directories to visit
if dir.current_is_dir():
dirs.push_back(dir.get_current_dir() + "/" + file_name)
# If a file, check if we already have a record for the same name
else:
var path := dir.get_current_dir() + ("/" if not first else "") + file_name
# grab all
if not p_match and not p_match_file_extensions:
data.append(path)
# grab matching strings
elif not p_match_is_regex and p_match and file_name.contains(p_match):
data.append(path)
# garb matching file extension
elif p_match_file_extensions and file_name.get_extension() in p_match_file_extensions:
data.append(path)
# grab matching regex
elif p_match_is_regex:
var regex_match := regex.search(path)
if regex_match != null:
data.append(path)
# Move on to the next file in this directory
file_name = dir.get_next()
# We've exhausted all files in this directory. Close the iterator.
dir.list_dir_end()
return data
static func copy_file(from: String, to: String) -> void:
ModLoaderSetupLog.debug("Copy file from: \"%s\" to: \"%s\"" % [from, to], LOG_NAME)
var global_to_path := ProjectSettings.globalize_path(to.get_base_dir())
if not DirAccess.dir_exists_absolute(global_to_path):
ModLoaderSetupLog.debug("Creating dir \"%s\"" % global_to_path, LOG_NAME)
DirAccess.make_dir_recursive_absolute(global_to_path)
var file_from := FileAccess.open(from, FileAccess.READ)
var file_from_error := file_from.get_error()
if not file_from_error == OK:
ModLoaderSetupLog.error("Error accessing file \"%s\": %s" % [from, error_string(file_from_error)], LOG_NAME)
return
var file_from_content := file_from.get_buffer(file_from.get_length())
var file_to := FileAccess.open(to, FileAccess.WRITE)
var file_to_error := file_to.get_error()
if not file_to_error == OK:
ModLoaderSetupLog.error("Error writing file \"%s\": %s" % [to, error_string(file_to_error)], LOG_NAME)
return
file_to.store_buffer(file_from_content)

View File

@@ -0,0 +1 @@
uid://2tin8kqukljx