mod_manifest.gd 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. class_name ModManifest
  2. extends Resource
  3. ##
  4. ## Stores and validates contents of the manifest set by the user
  5. const LOG_NAME := "ModLoader:ModManifest"
  6. # Validated by [method is_name_or_namespace_valid]
  7. ## Mod name.
  8. var name := ""
  9. # Validated by [method is_name_or_namespace_valid]
  10. ## Mod namespace, most commonly the main author.
  11. var mod_namespace := ""
  12. # Validated by [method is_semver_valid]
  13. ## Semantic version. Not a number, but required to be named like this by Thunderstore
  14. var version_number := "0.0.0"
  15. var description := ""
  16. var website_url := ""
  17. ## Used to determine mod load order
  18. var dependencies: PackedStringArray = []
  19. ## Used to determine mod load order
  20. var optional_dependencies: PackedStringArray = []
  21. ## only used for information
  22. var authors: PackedStringArray = []
  23. ## only used for information
  24. var compatible_game_version: PackedStringArray = []
  25. # Validated by [method _handle_compatible_mod_loader_version]
  26. ## only used for information
  27. var compatible_mod_loader_version: PackedStringArray = []
  28. ## only used for information
  29. var incompatibilities: PackedStringArray = []
  30. ## Used to determine mod load order
  31. var load_before: PackedStringArray = []
  32. ## only used for information
  33. var tags : PackedStringArray = []
  34. ## Schema for mod configs
  35. var config_schema := {}
  36. var description_rich := ""
  37. var image: CompressedTexture2D
  38. ## only used for information
  39. var steam_workshop_id := ""
  40. var validation_messages_error : Array[String] = []
  41. var validation_messages_warning : Array[String] = []
  42. var is_valid := false
  43. var has_parsing_failed := false
  44. # Required keys in a mod's manifest.json file
  45. const REQUIRED_MANIFEST_KEYS_ROOT: Array[String] = [
  46. "name",
  47. "namespace",
  48. "version_number",
  49. "website_url",
  50. "description",
  51. "dependencies",
  52. "extra",
  53. ]
  54. # Required keys in manifest's `json.extra.godot`
  55. const REQUIRED_MANIFEST_KEYS_EXTRA: Array[String] = [
  56. "authors",
  57. "compatible_mod_loader_version",
  58. "compatible_game_version",
  59. ]
  60. # Takes the manifest as [Dictionary] and validates everything.
  61. # Will return null if something is invalid.
  62. func _init(manifest: Dictionary, path: String) -> void:
  63. if manifest.is_empty():
  64. validation_messages_error.push_back("The manifest cannot be validated due to missing data, most likely because parsing the manifest.json file failed.")
  65. has_parsing_failed = true
  66. else:
  67. is_valid = validate(manifest, path)
  68. func validate(manifest: Dictionary, path: String) -> bool:
  69. var missing_fields: Array[String] = []
  70. missing_fields.append_array(ModLoaderUtils.get_missing_dict_fields(manifest, REQUIRED_MANIFEST_KEYS_ROOT))
  71. missing_fields.append_array(ModLoaderUtils.get_missing_dict_fields(manifest.extra, ["godot"]))
  72. missing_fields.append_array(ModLoaderUtils.get_missing_dict_fields(manifest.extra.godot, REQUIRED_MANIFEST_KEYS_EXTRA))
  73. if not missing_fields.is_empty():
  74. validation_messages_error.push_back("Manifest is missing required fields: %s" % str(missing_fields))
  75. name = manifest.name
  76. mod_namespace = manifest.namespace
  77. version_number = manifest.version_number
  78. is_name_or_namespace_valid(name)
  79. is_name_or_namespace_valid(mod_namespace)
  80. var mod_id = get_mod_id()
  81. is_semver_valid(mod_id, version_number, "version_number")
  82. description = manifest.description
  83. website_url = manifest.website_url
  84. dependencies = manifest.dependencies
  85. var godot_details: Dictionary = manifest.extra.godot
  86. authors = ModLoaderUtils.get_array_from_dict(godot_details, "authors")
  87. optional_dependencies = ModLoaderUtils.get_array_from_dict(godot_details, "optional_dependencies")
  88. incompatibilities = ModLoaderUtils.get_array_from_dict(godot_details, "incompatibilities")
  89. load_before = ModLoaderUtils.get_array_from_dict(godot_details, "load_before")
  90. compatible_game_version = ModLoaderUtils.get_array_from_dict(godot_details, "compatible_game_version")
  91. compatible_mod_loader_version = _handle_compatible_mod_loader_version(mod_id, godot_details)
  92. description_rich = ModLoaderUtils.get_string_from_dict(godot_details, "description_rich")
  93. tags = ModLoaderUtils.get_array_from_dict(godot_details, "tags")
  94. config_schema = ModLoaderUtils.get_dict_from_dict(godot_details, "config_schema")
  95. steam_workshop_id = ModLoaderUtils.get_string_from_dict(godot_details, "steam_workshop_id")
  96. if ModLoaderStore.ml_options.game_version_validation == ModLoaderOptionsProfile.VERSION_VALIDATION.DEFAULT:
  97. _is_game_version_compatible(mod_id)
  98. if ModLoaderStore.ml_options.game_version_validation == ModLoaderOptionsProfile.VERSION_VALIDATION.CUSTOM:
  99. if ModLoaderStore.ml_options.custom_game_version_validation_callable:
  100. ModLoaderStore.ml_options.custom_game_version_validation_callable.call(self)
  101. else:
  102. ModLoaderLog.error("No custom game version validation callable detected. Please provide a valid validation callable.", LOG_NAME)
  103. is_mod_id_array_valid(mod_id, dependencies, "dependency")
  104. is_mod_id_array_valid(mod_id, incompatibilities, "incompatibility")
  105. is_mod_id_array_valid(mod_id, optional_dependencies, "optional_dependency")
  106. is_mod_id_array_valid(mod_id, load_before, "load_before")
  107. validate_distinct_mod_ids_in_arrays(mod_id, dependencies, incompatibilities, ["dependencies", "incompatibilities"])
  108. validate_distinct_mod_ids_in_arrays(mod_id, optional_dependencies, dependencies, ["optional_dependencies", "dependencies"])
  109. validate_distinct_mod_ids_in_arrays(mod_id, optional_dependencies, incompatibilities, ["optional_dependencies", "incompatibilities"])
  110. validate_distinct_mod_ids_in_arrays(
  111. mod_id,
  112. load_before,
  113. dependencies,
  114. ["load_before", "dependencies"],
  115. "\"load_before\" should be handled as optional dependency adding it to \"dependencies\" will cancel out the desired effect."
  116. )
  117. validate_distinct_mod_ids_in_arrays(
  118. mod_id,
  119. load_before,
  120. optional_dependencies,
  121. ["load_before", "optional_dependencies"],
  122. "\"load_before\" can be viewed as optional dependency, please remove the duplicate mod-id."
  123. )
  124. validate_distinct_mod_ids_in_arrays(mod_id,load_before,incompatibilities,["load_before", "incompatibilities"])
  125. _validate_workshop_id(path)
  126. return validation_messages_error.is_empty()
  127. # Mod ID used in the mod loader
  128. # Format: {namespace}-{name}
  129. func get_mod_id() -> String:
  130. return "%s-%s" % [mod_namespace, name]
  131. # Package ID used by Thunderstore
  132. # Format: {namespace}-{name}-{version_number}
  133. func get_package_id() -> String:
  134. return "%s-%s-%s" % [mod_namespace, name, version_number]
  135. # Returns the Manifest values as a dictionary
  136. func get_as_dict() -> Dictionary:
  137. return {
  138. "name": name,
  139. "namespace": mod_namespace,
  140. "version_number": version_number,
  141. "description": description,
  142. "website_url": website_url,
  143. "dependencies": dependencies,
  144. "optional_dependencies": optional_dependencies,
  145. "authors": authors,
  146. "compatible_game_version": compatible_game_version,
  147. "compatible_mod_loader_version": compatible_mod_loader_version,
  148. "incompatibilities": incompatibilities,
  149. "load_before": load_before,
  150. "tags": tags,
  151. "config_schema": config_schema,
  152. "description_rich": description_rich,
  153. "image": image,
  154. }
  155. # Returns the Manifest values as JSON, in the manifest.json format
  156. func to_json() -> String:
  157. return JSON.stringify({
  158. "name": name,
  159. "namespace": mod_namespace,
  160. "version_number": version_number,
  161. "description": description,
  162. "website_url": website_url,
  163. "dependencies": dependencies,
  164. "extra": {
  165. "godot":{
  166. "authors": authors,
  167. "optional_dependencies": optional_dependencies,
  168. "compatible_game_version": compatible_game_version,
  169. "compatible_mod_loader_version": compatible_mod_loader_version,
  170. "incompatibilities": incompatibilities,
  171. "load_before": load_before,
  172. "tags": tags,
  173. "config_schema": config_schema,
  174. "description_rich": description_rich,
  175. "image": image,
  176. }
  177. }
  178. }, "\t")
  179. # Loads the default configuration for a mod.
  180. func load_mod_config_defaults() -> ModConfig:
  181. var default_config_save_path := _ModLoaderPath.get_path_to_mod_config_file(get_mod_id(), ModLoaderConfig.DEFAULT_CONFIG_NAME)
  182. var config := ModConfig.new(
  183. get_mod_id(),
  184. {},
  185. default_config_save_path,
  186. config_schema
  187. )
  188. # Check if there is no default.json file in the mods config directory
  189. if not _ModLoaderFile.file_exists(config.save_path):
  190. # Generate config_default based on the default values in config_schema
  191. config.data = _generate_default_config_from_schema(config.schema.properties)
  192. # If the default.json file exists
  193. else:
  194. var current_schema_md5 := config.get_schema_as_string().md5_text()
  195. var cache_schema_md5s := _ModLoaderCache.get_data("config_schemas")
  196. var cache_schema_md5: String = cache_schema_md5s[config.mod_id] if cache_schema_md5s.has(config.mod_id) else ''
  197. # Generate a new default config if the config schema has changed or there is nothing cached
  198. if not current_schema_md5 == cache_schema_md5 or cache_schema_md5.is_empty():
  199. config.data = _generate_default_config_from_schema(config.schema.properties)
  200. # If the config schema has not changed just load the json file
  201. else:
  202. config.data = _ModLoaderFile.get_json_as_dict(config.save_path)
  203. # Validate the config defaults
  204. if config.is_valid():
  205. # Create the default config file
  206. config.save_to_file()
  207. # Store the md5 of the config schema in the cache
  208. _ModLoaderCache.update_data("config_schemas", {config.mod_id: config.get_schema_as_string().md5_text()} )
  209. # Return the default ModConfig
  210. return config
  211. ModLoaderLog.fatal("The default config values for %s-%s are invalid. Configs will not be loaded." % [mod_namespace, name], LOG_NAME)
  212. return null
  213. # Recursively searches for default values
  214. func _generate_default_config_from_schema(property: Dictionary, current_prop := {}) -> Dictionary:
  215. # Exit function if property is empty
  216. if property.is_empty():
  217. return current_prop
  218. for property_key in property.keys():
  219. var prop = property[property_key]
  220. # If this property contains nested properties, we recursively call this function
  221. if "properties" in prop:
  222. current_prop[property_key] = {}
  223. _generate_default_config_from_schema(prop.properties, current_prop[property_key])
  224. # Return early here because a object will not have a "default" key
  225. return current_prop
  226. # If this property contains a default value, add it to the global config_defaults dictionary
  227. if JSONSchema.JSKW_DEFAULT in prop:
  228. # Initialize the current_key if it is missing in config_defaults
  229. if not current_prop.has(property_key):
  230. current_prop[property_key] = {}
  231. # Add the default value to the config_defaults
  232. current_prop[property_key] = prop.default
  233. return current_prop
  234. # Handles deprecation of the single string value in the compatible_mod_loader_version.
  235. func _handle_compatible_mod_loader_version(mod_id: String, godot_details: Dictionary) -> Array:
  236. var link_manifest_docs := "https://github.com/GodotModding/godot-mod-loader/wiki/Mod-Files#manifestjson"
  237. var array_value := ModLoaderUtils.get_array_from_dict(godot_details, "compatible_mod_loader_version")
  238. # If there are array values
  239. if array_value.size() > 0:
  240. # Check for valid versions
  241. if not is_semver_version_array_valid(mod_id, array_value, "compatible_mod_loader_version"):
  242. return []
  243. return array_value
  244. # If the array is empty check if a string was passed
  245. var string_value := ModLoaderUtils.get_string_from_dict(godot_details, "compatible_mod_loader_version")
  246. # If an empty string was passed
  247. if string_value == "":
  248. # Using str() here because format strings caused an error
  249. validation_messages_error.push_back(
  250. str (
  251. "%s - \"compatible_mod_loader_version\" is a required field." +
  252. " For more details visit %s"
  253. ) % [mod_id, link_manifest_docs])
  254. return []
  255. return [string_value]
  256. # A valid namespace may only use letters (any case), numbers and underscores
  257. # and has to be longer than 3 characters
  258. # a-z A-Z 0-9 _ (longer than 3 characters)
  259. func is_name_or_namespace_valid(check_name: String, is_silent := false) -> bool:
  260. var re := RegEx.new()
  261. var _compile_error_1 = re.compile("^[a-zA-Z0-9_]*$") # alphanumeric and _
  262. if re.search(check_name) == null:
  263. if not is_silent:
  264. validation_messages_error.push_back("Invalid name or namespace: \"%s\". You may only use letters, numbers and underscores." % check_name)
  265. return false
  266. var _compile_error_2 = re.compile("^[a-zA-Z0-9_]{3,}$") # at least 3 long
  267. if re.search(check_name) == null:
  268. if not is_silent:
  269. validation_messages_error.push_back("Invalid name or namespace: \"%s\". Must be longer than 3 characters." % check_name)
  270. return false
  271. return true
  272. func is_semver_version_array_valid(mod_id: String, version_array: PackedStringArray, version_array_descripton: String, is_silent := false) -> bool:
  273. var is_valid := true
  274. for version in version_array:
  275. if not is_semver_valid(mod_id, version, version_array_descripton, is_silent):
  276. is_valid = false
  277. return is_valid
  278. # A valid semantic version should follow this format: {mayor}.{minor}.{patch}
  279. # reference https://semver.org/ for details
  280. # {0-9}.{0-9}.{0-9} (no leading 0, shorter than 16 characters total)
  281. func is_semver_valid(mod_id: String, check_version_number: String, field_name: String, is_silent := false) -> bool:
  282. var re := RegEx.new()
  283. var _compile_error = re.compile("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")
  284. if re.search(check_version_number) == null:
  285. if not is_silent:
  286. # Using str() here because format strings caused an error
  287. validation_messages_error.push_back(
  288. str(
  289. "Invalid semantic version: \"%s\" in field \"%s\" of mod \"%s\". " +
  290. "You may only use numbers without leading zero and periods " +
  291. "following this format {mayor}.{minor}.{patch}"
  292. ) % [check_version_number, field_name, mod_id]
  293. )
  294. return false
  295. if check_version_number.length() > 16:
  296. if not is_silent:
  297. validation_messages_error.push_back(
  298. str(
  299. "Invalid semantic version: \"%s\" in field \"%s\" of mod \"%s\". " +
  300. "Version number must be shorter than 16 characters."
  301. ) % [check_version_number, field_name, mod_id]
  302. )
  303. return false
  304. return true
  305. func validate_distinct_mod_ids_in_arrays(
  306. mod_id: String,
  307. array_one: PackedStringArray,
  308. array_two: PackedStringArray,
  309. array_description: PackedStringArray,
  310. additional_info := "",
  311. is_silent := false
  312. ) -> bool:
  313. # Initialize an empty array to hold any overlaps.
  314. var overlaps: PackedStringArray = []
  315. # Loop through each incompatibility and check if it is also listed as a dependency.
  316. for loop_mod_id in array_one:
  317. if array_two.has(loop_mod_id):
  318. overlaps.push_back(loop_mod_id)
  319. # If no overlaps were found
  320. if overlaps.size() == 0:
  321. return true
  322. # If any overlaps were found
  323. if not is_silent:
  324. validation_messages_error.push_back(
  325. (
  326. "The mod -> %s lists the same mod(s) -> %s - in \"%s\" and \"%s\". %s"
  327. % [mod_id, overlaps, array_description[0], array_description[1], additional_info]
  328. )
  329. )
  330. return false
  331. # If silent just return false
  332. return false
  333. func is_mod_id_array_valid(own_mod_id: String, mod_id_array: PackedStringArray, mod_id_array_description: String, is_silent := false) -> bool:
  334. var is_valid := true
  335. # If there are mod ids
  336. if mod_id_array.size() > 0:
  337. for mod_id in mod_id_array:
  338. # Check if mod id is the same as the mods mod id.
  339. if mod_id == own_mod_id:
  340. is_valid = false
  341. if not is_silent:
  342. validation_messages_error.push_back("The mod \"%s\" lists itself as \"%s\" in its own manifest.json file" % [mod_id, mod_id_array_description])
  343. # Check if the mod id is a valid mod id.
  344. if not is_mod_id_valid(own_mod_id, mod_id, mod_id_array_description, is_silent):
  345. is_valid = false
  346. return is_valid
  347. func is_mod_id_valid(original_mod_id: String, check_mod_id: String, type := "", is_silent := false) -> bool:
  348. var intro_text = "A %s for the mod \"%s\" is invalid: " % [type, original_mod_id] if not type == "" else ""
  349. # contains hyphen?
  350. if not check_mod_id.count("-") == 1:
  351. if not is_silent:
  352. 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]))
  353. return false
  354. # at least 7 long (1 for hyphen, 3 each for namespace/name)
  355. var mod_id_length = check_mod_id.length()
  356. if mod_id_length < 7:
  357. if not is_silent:
  358. 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]))
  359. return false
  360. var split = check_mod_id.split("-")
  361. var check_namespace = split[0]
  362. var check_name = split[1]
  363. var re := RegEx.new()
  364. re.compile("^[a-zA-Z0-9_]{3,}$") # alphanumeric and _ and at least 3 characters
  365. if re.search(check_namespace) == null:
  366. if not is_silent:
  367. 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]))
  368. return false
  369. if re.search(check_name) == null:
  370. if not is_silent:
  371. 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]))
  372. return false
  373. return true
  374. func is_string_length_valid(mod_id: String, field: String, string: String, required_length: int, is_silent := false) -> bool:
  375. if not string.length() == required_length:
  376. if not is_silent:
  377. 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()])
  378. return false
  379. return true
  380. # Validates the workshop id separately from the rest since it needs the ModData
  381. func _validate_workshop_id(path: String) -> void:
  382. var steam_workshop_id_from_path := _ModLoaderPath.get_steam_workshop_id(path)
  383. var is_mod_source_workshop := not steam_workshop_id_from_path.is_empty()
  384. if not _is_steam_workshop_id_valid(get_mod_id(), steam_workshop_id_from_path, steam_workshop_id, is_mod_source_workshop):
  385. # Override the invalid steam_workshop_id if we load from the workshop
  386. if is_mod_source_workshop:
  387. steam_workshop_id = steam_workshop_id_from_path
  388. 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:
  389. if steam_workshop_id_to_validate.is_empty():
  390. # Workshop id is optional, so we return true if no id is given
  391. return true
  392. # Validate the steam_workshop_id based on the zip_path if the mod is loaded from the workshop
  393. if is_mod_source_workshop:
  394. if not steam_workshop_id_to_validate == steam_workshop_id_from_path:
  395. if not is_silent:
  396. 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)
  397. return false
  398. else:
  399. if not is_string_length_valid(mod_id, "steam_workshop_id", steam_workshop_id_to_validate, 10, is_silent):
  400. # Invalidate the manifest in this case because the mod is most likely in development if it is not loaded from the steam workshop.
  401. return false
  402. return true
  403. func _is_game_version_compatible(mod_id: String) -> bool:
  404. var game_version: String = ModLoaderStore.ml_options.semantic_version
  405. var game_major := int(game_version.get_slice(".", 0))
  406. var game_minor := int(game_version.get_slice(".", 1))
  407. var valid_major := false
  408. var valid_minor := false
  409. for version in compatible_game_version:
  410. var compat_major := int(version.get_slice(".", 0))
  411. var compat_minor := int(version.get_slice(".", 1))
  412. if compat_major < game_major:
  413. continue
  414. valid_major = true
  415. if compat_minor < game_minor:
  416. continue
  417. valid_minor = true
  418. if not valid_major:
  419. validation_messages_error.push_back(
  420. "The mod \"%s\" is incompatible with the current game version.
  421. (current game version: %s, mod compatible with game versions: %s)" %
  422. [mod_id, game_version, compatible_game_version]
  423. )
  424. return false
  425. if not valid_minor:
  426. validation_messages_warning.push_back(
  427. "The mod \"%s\" may not be compatible with the current game version.
  428. Enable at your own risk. (current game version: %s, mod compatible with game versions: %s)" %
  429. [mod_id, game_version, compatible_game_version]
  430. )
  431. return true
  432. return true