| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536 |
- 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
|