mirror of
https://github.com/JHDev2006/Super-Mario-Bros.-Remastered-Public.git
synced 2025-10-22 15:38:14 +00:00
added the game
This commit is contained in:
121
addons/mod_loader/LICENSE
Normal file
121
addons/mod_loader/LICENSE
Normal 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.
|
27
addons/mod_loader/_export_plugin/export_plugin.gd
Normal file
27
addons/mod_loader/_export_plugin/export_plugin.gd
Normal 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
|
||||
)
|
1
addons/mod_loader/_export_plugin/export_plugin.gd.uid
Normal file
1
addons/mod_loader/_export_plugin/export_plugin.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b0csnkkiudklo
|
7
addons/mod_loader/_export_plugin/plugin.cfg
Normal file
7
addons/mod_loader/_export_plugin/plugin.cfg
Normal 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"
|
14
addons/mod_loader/_export_plugin/plugin.gd
Normal file
14
addons/mod_loader/_export_plugin/plugin.gd
Normal 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)
|
1
addons/mod_loader/_export_plugin/plugin.gd.uid
Normal file
1
addons/mod_loader/_export_plugin/plugin.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://l36n5fcc565s
|
410
addons/mod_loader/api/config.gd
Normal file
410
addons/mod_loader/api/config.gd
Normal 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)
|
1
addons/mod_loader/api/config.gd.uid
Normal file
1
addons/mod_loader/api/config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://byhbvq7il70cy
|
76
addons/mod_loader/api/deprecated.gd
Normal file
76
addons/mod_loader/api/deprecated.gd
Normal 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)
|
1
addons/mod_loader/api/deprecated.gd.uid
Normal file
1
addons/mod_loader/api/deprecated.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://2sifoxblubxv
|
83
addons/mod_loader/api/hook_chain.gd
Normal file
83
addons/mod_loader/api/hook_chain.gd
Normal 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
|
1
addons/mod_loader/api/hook_chain.gd.uid
Normal file
1
addons/mod_loader/api/hook_chain.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://nyep44jvp7yc
|
599
addons/mod_loader/api/log.gd
Normal file
599
addons/mod_loader/api/log.gd
Normal 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
|
1
addons/mod_loader/api/log.gd.uid
Normal file
1
addons/mod_loader/api/log.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dfoleo2pforxu
|
358
addons/mod_loader/api/mod.gd
Normal file
358
addons/mod_loader/api/mod.gd
Normal 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
|
1
addons/mod_loader/api/mod.gd.uid
Normal file
1
addons/mod_loader/api/mod.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2hugw88f3q4e
|
475
addons/mod_loader/api/profile.gd
Normal file
475
addons/mod_loader/api/profile.gd
Normal 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)
|
1
addons/mod_loader/api/profile.gd.uid
Normal file
1
addons/mod_loader/api/profile.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c0u28df0ffhan
|
85
addons/mod_loader/internal/cache.gd
Normal file
85
addons/mod_loader/internal/cache.gd
Normal 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)
|
1
addons/mod_loader/internal/cache.gd.uid
Normal file
1
addons/mod_loader/internal/cache.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b73enisoxe0uq
|
85
addons/mod_loader/internal/cli.gd
Normal file
85
addons/mod_loader/internal/cli.gd
Normal 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
|
1
addons/mod_loader/internal/cli.gd.uid
Normal file
1
addons/mod_loader/internal/cli.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c3rvk5ry6rqyq
|
131
addons/mod_loader/internal/dependency.gd
Normal file
131
addons/mod_loader/internal/dependency.gd
Normal 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
|
1
addons/mod_loader/internal/dependency.gd.uid
Normal file
1
addons/mod_loader/internal/dependency.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bnmjbsvid8sxk
|
241
addons/mod_loader/internal/file.gd
Normal file
241
addons/mod_loader/internal/file.gd
Normal 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
|
1
addons/mod_loader/internal/file.gd.uid
Normal file
1
addons/mod_loader/internal/file.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d34sgvhw73mtb
|
116
addons/mod_loader/internal/godot.gd
Normal file
116
addons/mod_loader/internal/godot.gd
Normal 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
|
1
addons/mod_loader/internal/godot.gd.uid
Normal file
1
addons/mod_loader/internal/godot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dc86ulvsd1da7
|
60
addons/mod_loader/internal/hooks.gd
Normal file
60
addons/mod_loader/internal/hooks.gd
Normal 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)
|
1
addons/mod_loader/internal/hooks.gd.uid
Normal file
1
addons/mod_loader/internal/hooks.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://yrog1crr7kxp
|
74
addons/mod_loader/internal/mod_hook_packer.gd
Normal file
74
addons/mod_loader/internal/mod_hook_packer.gd
Normal 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()
|
1
addons/mod_loader/internal/mod_hook_packer.gd.uid
Normal file
1
addons/mod_loader/internal/mod_hook_packer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnwq8741pln26
|
649
addons/mod_loader/internal/mod_hook_preprocessor.gd
Normal file
649
addons/mod_loader/internal/mod_hook_preprocessor.gd
Normal 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
|
1
addons/mod_loader/internal/mod_hook_preprocessor.gd.uid
Normal file
1
addons/mod_loader/internal/mod_hook_preprocessor.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://gxlbhvqctoix
|
106
addons/mod_loader/internal/mod_loader_utils.gd
Normal file
106
addons/mod_loader/internal/mod_loader_utils.gd
Normal 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
|
1
addons/mod_loader/internal/mod_loader_utils.gd.uid
Normal file
1
addons/mod_loader/internal/mod_loader_utils.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bk8dltcgr6n5d
|
293
addons/mod_loader/internal/path.gd
Normal file
293
addons/mod_loader/internal/path.gd
Normal 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)
|
1
addons/mod_loader/internal/path.gd.uid
Normal file
1
addons/mod_loader/internal/path.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dahg6tbvgiy3q
|
56
addons/mod_loader/internal/scene_extension.gd
Normal file
56
addons/mod_loader/internal/scene_extension.gd
Normal 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)
|
1
addons/mod_loader/internal/scene_extension.gd.uid
Normal file
1
addons/mod_loader/internal/scene_extension.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://daijyovwv2vnr
|
156
addons/mod_loader/internal/script_extension.gd
Normal file
156
addons/mod_loader/internal/script_extension.gd
Normal 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()
|
1
addons/mod_loader/internal/script_extension.gd.uid
Normal file
1
addons/mod_loader/internal/script_extension.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dinxouhn4hk1d
|
108
addons/mod_loader/internal/third_party/steam.gd
vendored
Normal file
108
addons/mod_loader/internal/third_party/steam.gd
vendored
Normal 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
|
1
addons/mod_loader/internal/third_party/steam.gd.uid
vendored
Normal file
1
addons/mod_loader/internal/third_party/steam.gd.uid
vendored
Normal file
@@ -0,0 +1 @@
|
||||
uid://br0xd56w758rg
|
256
addons/mod_loader/mod_loader.gd
Normal file
256
addons/mod_loader/mod_loader.gd
Normal 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)
|
1
addons/mod_loader/mod_loader.gd.uid
Normal file
1
addons/mod_loader/mod_loader.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bd8npc3ai2vv3
|
230
addons/mod_loader/mod_loader_store.gd
Normal file
230
addons/mod_loader/mod_loader_store.gd
Normal 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
|
1
addons/mod_loader/mod_loader_store.gd.uid
Normal file
1
addons/mod_loader/mod_loader_store.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://jv5kxtsw7sfj
|
12
addons/mod_loader/options/options.tres
Normal file
12
addons/mod_loader/options/options.tres
Normal 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")
|
||||
}
|
13
addons/mod_loader/options/profiles/current.tres
Normal file
13
addons/mod_loader/options/profiles/current.tres
Normal 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 = ""
|
14
addons/mod_loader/options/profiles/default.tres
Normal file
14
addons/mod_loader/options/profiles/default.tres
Normal 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 = ""
|
14
addons/mod_loader/options/profiles/disable_mods.tres
Normal file
14
addons/mod_loader/options/profiles/disable_mods.tres
Normal 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 = ""
|
17
addons/mod_loader/options/profiles/editor.tres
Normal file
17
addons/mod_loader/options/profiles/editor.tres
Normal 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 = [ ]
|
@@ -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
|
19
addons/mod_loader/options/profiles/production_workshop.tres
Normal file
19
addons/mod_loader/options/profiles/production_workshop.tres
Normal 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
|
82
addons/mod_loader/resources/mod_config.gd
Normal file
82
addons/mod_loader/resources/mod_config.gd
Normal 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
|
1
addons/mod_loader/resources/mod_config.gd.uid
Normal file
1
addons/mod_loader/resources/mod_config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bjfjju1edaxwv
|
220
addons/mod_loader/resources/mod_data.gd
Normal file
220
addons/mod_loader/resources/mod_data.gd
Normal 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
|
1
addons/mod_loader/resources/mod_data.gd.uid
Normal file
1
addons/mod_loader/resources/mod_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bfnhjikkx0g5s
|
536
addons/mod_loader/resources/mod_manifest.gd
Normal file
536
addons/mod_loader/resources/mod_manifest.gd
Normal 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
|
1
addons/mod_loader/resources/mod_manifest.gd.uid
Normal file
1
addons/mod_loader/resources/mod_manifest.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bleh3oamdbmnr
|
22
addons/mod_loader/resources/mod_user_profile.gd
Normal file
22
addons/mod_loader/resources/mod_user_profile.gd
Normal 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
|
1
addons/mod_loader/resources/mod_user_profile.gd.uid
Normal file
1
addons/mod_loader/resources/mod_user_profile.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ddrlbkscua6n0
|
15
addons/mod_loader/resources/options_current.gd
Normal file
15
addons/mod_loader/resources/options_current.gd
Normal 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")
|
||||
}
|
1
addons/mod_loader/resources/options_current.gd.uid
Normal file
1
addons/mod_loader/resources/options_current.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cmxtu4snlj1bb
|
119
addons/mod_loader/resources/options_profile.gd
Normal file
119
addons/mod_loader/resources/options_profile.gd
Normal 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
|
1
addons/mod_loader/resources/options_profile.gd.uid
Normal file
1
addons/mod_loader/resources/options_profile.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dsbicisgihjet
|
34
addons/mod_loader/restart_notification.gd
Normal file
34
addons/mod_loader/restart_notification.gd
Normal 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()
|
1
addons/mod_loader/restart_notification.gd.uid
Normal file
1
addons/mod_loader/restart_notification.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://k10oyyxy00y1
|
95
addons/mod_loader/restart_notification.tscn
Normal file
95
addons/mod_loader/restart_notification.tscn
Normal 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
|
163
addons/mod_loader/setup/global_script_class_cache_mod_loader.cfg
Normal file
163
addons/mod_loader/setup/global_script_class_cache_mod_loader.cfg
Normal 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"
|
||||
}])
|
209
addons/mod_loader/setup/setup_log.gd
Normal file
209
addons/mod_loader/setup/setup_log.gd
Normal 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)
|
1
addons/mod_loader/setup/setup_log.gd.uid
Normal file
1
addons/mod_loader/setup/setup_log.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://djchjoj06bcko
|
286
addons/mod_loader/setup/setup_utils.gd
Normal file
286
addons/mod_loader/setup/setup_utils.gd
Normal 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)
|
1
addons/mod_loader/setup/setup_utils.gd.uid
Normal file
1
addons/mod_loader/setup/setup_utils.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://2tin8kqukljx
|
Reference in New Issue
Block a user