gdharness 1.1.21 → 1.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/build/cli.js +1036 -525
  2. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  3. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  9. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  10. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  13. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  14. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
  23. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
  24. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  25. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  26. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  28. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  29. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  31. package/build/godot/operations/audio_buses.gd +47 -11
  32. package/build/godot/operations/class_cache.gd +18 -2
  33. package/build/godot/operations/dependencies.gd +128 -17
  34. package/build/godot/operations/file_walk.gd +20 -0
  35. package/build/godot/operations/gdscript_analysis.gd +59 -20
  36. package/build/godot/operations/gdscript_authoring.gd +127 -60
  37. package/build/godot/operations/godot_operations.gd +24 -5
  38. package/build/godot/operations/import_pipeline.gd +179 -19
  39. package/build/godot/operations/logger.gd +2 -2
  40. package/build/godot/operations/plugins.gd +88 -70
  41. package/build/godot/operations/project_config.gd +41 -6
  42. package/build/godot/operations/project_diagnostics.gd +25 -10
  43. package/build/godot/operations/reading.gd +17 -0
  44. package/build/godot/operations/resource_files.gd +60 -17
  45. package/build/godot/operations/serialisation.gd +116 -7
  46. package/build/index.js +949 -435
  47. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  extends RefCounted
2
2
 
3
3
  const Read = preload("reading.gd")
4
+ const Serialisation = preload("serialisation.gd")
4
5
  const FileWalk = preload("file_walk.gd")
5
6
  const Log = preload("logger.gd")
6
7
 
@@ -37,11 +38,20 @@ const IMPORTABLE_EXTENSIONS: Array[String] = [
37
38
  "obj",
38
39
  "dae",
39
40
  ]
41
+ ## The format version each importer writes as `importer_version` where it is above 0, which the
42
+ ## editor reimports anything older than. Read off sidecars 4.7.2 writes: scene and animation_library
43
+ ## write 1, and the texture importers write none. A case imports one fresh and compares, so an engine
44
+ ## that raises one fails it rather than leaving this behind.
45
+ const IMPORTER_VERSIONS: Dictionary[String, int] = {"scene": 1, "animation_library": 1}
46
+ ## The VRAM compression formats a project can ask for, each behind its
47
+ ## `rendering/textures/vram_compression/import_<format>` setting.
48
+ const VRAM_FORMATS: Array[String] = ["s3tc_bptc", "etc2_astc"]
40
49
  const EDITOR_DIRECTORY: String = "res://.godot/editor"
41
50
  const EDITOR_CACHE_PREFIX: String = "filesystem_cache"
42
51
 
43
52
  var _log: Log
44
53
  var _files: FileWalk = FileWalk.new()
54
+ var _values: Serialisation = Serialisation.new()
45
55
  var _sidecar_md5s: Variant = null
46
56
 
47
57
 
@@ -63,7 +73,7 @@ func get_import_status(params: Dictionary) -> Dictionary:
63
73
 
64
74
  var resources: Array[Dictionary] = []
65
75
  var summary: Dictionary = {
66
- "total": 0, "needs_reimport": 0, "failed": 0, "up_to_date": 0, "missing_source": 0
76
+ "total": 0, "needs_reimport": 0, "failed": 0, "up_to_date": 0, "missing_source": 0, "not_imported": 0
67
77
  }
68
78
 
69
79
  if not resource_path.is_empty():
@@ -107,8 +117,7 @@ func get_import_options(params: Dictionary) -> Dictionary:
107
117
  var import_file_path: String = resource_path + ".import"
108
118
 
109
119
  if not FileAccess.file_exists(import_file_path):
110
- _log.error("Import file does not exist: " + import_file_path)
111
- return _log.failure("This resource may not have been imported yet")
120
+ return _log.failure(_no_sidecar(resource_path))
112
121
 
113
122
  var config: ConfigFile = ConfigFile.new()
114
123
  var err: Error = config.load(import_file_path)
@@ -120,11 +129,14 @@ func get_import_options(params: Dictionary) -> Dictionary:
120
129
  "resource_path": resource_path, "import_file": import_file_path, "remap": {}, "deps": {}, "params": {}
121
130
  }
122
131
 
132
+ # Through the serialiser, as every other read answers: an engine type written straight into
133
+ # JSON is its own text, so a Vector3 option came back as "(1, 1, 1)", and writing that back put
134
+ # a string in the sidecar where the importer reads a vector.
123
135
  for section: String in ["remap", "deps", "params"]:
124
136
  if config.has_section(section):
125
137
  var values: Dictionary = result[section]
126
138
  for key: String in config.get_section_keys(section):
127
- values[key] = config.get_value(section, key)
139
+ values[key] = _values.serialize_value(config.get_value(section, key))
128
140
 
129
141
  return result
130
142
 
@@ -142,8 +154,12 @@ func set_import_options(params: Dictionary) -> Dictionary:
142
154
  var import_file_path: String = resource_path + ".import"
143
155
 
144
156
  if not FileAccess.file_exists(import_file_path):
145
- _log.error("Import file does not exist: " + import_file_path)
146
- return _log.failure("This resource may not have been imported yet")
157
+ return _log.failure(_no_sidecar(resource_path))
158
+ # A sidecar left behind by a source that has gone takes options that nothing will ever apply.
159
+ if not FileAccess.file_exists(resource_path):
160
+ return _log.failure(
161
+ resource_path + " is not on disk, so there is nothing its import options apply to"
162
+ )
147
163
 
148
164
  var config: ConfigFile = ConfigFile.new()
149
165
  var err: Error = config.load(import_file_path)
@@ -151,13 +167,37 @@ func set_import_options(params: Dictionary) -> Dictionary:
151
167
  if err != OK:
152
168
  return _log.failure("Failed to parse import file: " + str(err))
153
169
 
154
- var updated_keys: Array[String] = []
170
+ # Every option checked before any is written, so a refused one leaves the sidecar as it was.
171
+ var fitted_values: Dictionary = {}
172
+ var refused: Array[String] = []
155
173
  for key: Variant in options:
156
174
  var name: String = str(key)
157
- var value: Variant = _as_option(options[key], config.get_value("params", name, null))
158
- config.set_value("params", name, value)
175
+ var held: Variant = config.get_value("params", name, null)
176
+ var given: Variant = _values.deserialize_value(options[key])
177
+ var value: Variant = _as_option(_values.fitted(options[key], typeof(held)), held)
178
+ # A number the option cannot hold whole is refused rather than truncated: 2.5 fitted to an
179
+ # enum option became 2, which is another setting.
180
+ var lossy: bool = (
181
+ (given is int or given is float)
182
+ and (value is int or value is float)
183
+ and type_convert(value, typeof(given)) != given
184
+ )
185
+ if held != null and (lossy or not Serialisation.acceptable(value, typeof(held))):
186
+ var held_type: String = type_string(typeof(held))
187
+ var article: String = "an" if held_type.to_lower()[0] in "aeiou" else "a"
188
+ refused.append(
189
+ "%s holds %s %s, and %s is not one" % [name, article, held_type, JSON.stringify(options[key])]
190
+ )
191
+ continue
192
+ fitted_values[name] = value
193
+ if not refused.is_empty():
194
+ return _log.failure("Nothing was written: " + "; ".join(refused))
195
+
196
+ var updated_keys: Array[String] = []
197
+ for name: String in fitted_values:
198
+ config.set_value("params", name, fitted_values[name])
159
199
  updated_keys.append(name)
160
- _log.debug("Set " + name + " = " + str(value))
200
+ _log.debug("Set " + name + " = " + str(fitted_values[name]))
161
201
 
162
202
  err = config.save(import_file_path)
163
203
  if err != OK:
@@ -178,6 +218,19 @@ static func _as_option(given: Variant, held: Variant) -> Variant:
178
218
  return given
179
219
 
180
220
 
221
+ # The names of the project's export presets, in the order export_presets.cfg holds them.
222
+ static func _preset_names() -> Array[String]:
223
+ var names: Array[String] = []
224
+ var config: ConfigFile = ConfigFile.new()
225
+ if config.load("res://export_presets.cfg") != OK:
226
+ return names
227
+ var index: int = 0
228
+ while config.has_section("preset." + str(index)):
229
+ names.append(str(config.get_value("preset." + str(index), "name", "")))
230
+ index += 1
231
+ return names
232
+
233
+
181
234
  # List export presets
182
235
  func list_export_presets(_params: Dictionary) -> Dictionary:
183
236
  _log.info("Listing export presets")
@@ -257,12 +310,12 @@ func validate_project(params: Dictionary) -> Dictionary:
257
310
  include_suggestions
258
311
  )
259
312
  )
260
- elif not FileAccess.file_exists(main_scene):
313
+ elif not FileWalk.missing_reason(main_scene).is_empty():
261
314
  issues.append(
262
315
  _finding(
263
316
  "error",
264
317
  "main_scene",
265
- "Main scene file does not exist: " + main_scene,
318
+ "Main scene " + FileWalk.missing_reason(main_scene),
266
319
  "Update the main scene setting or create the missing scene file",
267
320
  include_suggestions
268
321
  )
@@ -280,6 +333,30 @@ func validate_project(params: Dictionary) -> Dictionary:
280
333
  )
281
334
  )
282
335
 
336
+ # The preset asked about is looked for rather than only logged: validating "for" a preset that
337
+ # does not exist answered valid, as though it had been checked.
338
+ if not preset_name.is_empty():
339
+ checks_performed.append("export_preset")
340
+ var named: Array[String] = _preset_names()
341
+ if preset_name not in named:
342
+ issues.append(
343
+ _finding(
344
+ "error",
345
+ "export_preset",
346
+ (
347
+ "No export preset is named "
348
+ + preset_name
349
+ + (
350
+ ": the project has none"
351
+ if named.is_empty()
352
+ else ": the presets are " + ", ".join(named)
353
+ )
354
+ ),
355
+ "Pass one of the preset names, or add the preset in the editor: Project > Export",
356
+ include_suggestions
357
+ )
358
+ )
359
+
283
360
  checks_performed.append("icon")
284
361
  var icon_path: String = str(ProjectSettings.get_setting("application/config/icon", ""))
285
362
  if icon_path.is_empty():
@@ -323,11 +400,8 @@ func validate_project(params: Dictionary) -> Dictionary:
323
400
 
324
401
  # A hundred scripts is enough to say whether the project is tidy without a large one
325
402
  # turning validation into a full read of its source tree.
326
- for script_path: String in script_files:
403
+ for script_path: String in script_files.slice(0, 100):
327
404
  scripts_checked += 1
328
- if scripts_checked > 100:
329
- break
330
-
331
405
  var file: FileAccess = FileAccess.open(script_path, FileAccess.READ)
332
406
  if file:
333
407
  var content: String = file.get_as_text()
@@ -355,6 +429,8 @@ func validate_project(params: Dictionary) -> Dictionary:
355
429
  "warnings": warnings,
356
430
  "checks_performed": checks_performed,
357
431
  "scripts_checked": scripts_checked,
432
+ # Said beside the count, so a project of four hundred scripts is not read as checked whole.
433
+ "scripts_total": script_files.size(),
358
434
  "issue_count": issues.size(),
359
435
  "warning_count": warnings.size()
360
436
  }
@@ -389,10 +465,14 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
389
465
 
390
466
  var import_file_exists: bool = FileAccess.file_exists(import_file_path)
391
467
  if not import_file_exists:
468
+ # A file with no sidecar has not been imported only if it is something the engine imports.
469
+ # A scene answered as needing an import, and a reimport of it ran a pass and then reported
470
+ # the scene as not reimported, which reads as the import failing.
471
+ var never: String = _never_imported(resource_path)
392
472
  return {
393
473
  "path": resource_path,
394
- "status": "needs_reimport",
395
- "reason": "it has not been imported",
474
+ "status": "needs_reimport" if never.is_empty() else "not_imported",
475
+ "reason": "it has not been imported" if never.is_empty() else never,
396
476
  "import_file_exists": false,
397
477
  "source_exists": true
398
478
  }
@@ -402,7 +482,9 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
402
482
  }
403
483
 
404
484
  # The checks are the ones EditorFileSystem::_test_for_reimport makes, in its order, so a resource
405
- # reads as needing an import exactly when the editor's next scan would import it.
485
+ # reads as needing an import when the editor's next scan would import it. The importer's own two
486
+ # cannot be asked here, where no importer is loaded, so they are made from what was measured of
487
+ # the built-in ones; an importer a plugin adds is judged by its files alone.
406
488
  var sidecar: ConfigFile = ConfigFile.new()
407
489
  var stale: Dictionary = (
408
490
  _stale("its import file cannot be read")
@@ -416,6 +498,41 @@ func _import_status_of(resource_path: String, import_file_path: String) -> Dicti
416
498
  return status
417
499
 
418
500
 
501
+ # Why [param resource_path] has no import file, with what to do about it where anything can be done.
502
+ func _no_sidecar(resource_path: String) -> String:
503
+ if not FileAccess.file_exists(resource_path):
504
+ return resource_path + " is not on disk"
505
+ var never: String = _never_imported(resource_path)
506
+ if not never.is_empty():
507
+ return resource_path + " has no import options: " + never
508
+ return (
509
+ resource_path
510
+ + " has not been imported yet, so it has no import file: project_import reimport imports it"
511
+ )
512
+
513
+
514
+ # Why the engine will never import [param resource_path], or empty when it would.
515
+ func _never_imported(resource_path: String) -> String:
516
+ var folder: String = resource_path.get_base_dir()
517
+ while folder.begins_with("res://"):
518
+ if _files.is_stepped_over(folder if folder.ends_with("/") else folder + "/"):
519
+ return "a .gdignore in " + folder + " keeps the engine from importing anything under it"
520
+ if folder == "res://":
521
+ break
522
+ folder = folder.get_base_dir()
523
+ var extension: String = resource_path.get_extension().to_lower()
524
+ if extension in IMPORTABLE_EXTENSIONS:
525
+ return ""
526
+ # Loadable without a sidecar is what a scene, a script or a saved resource is: read as it is.
527
+ if ResourceLoader.exists(resource_path):
528
+ return "the engine loads ." + extension + " files as they are, without importing them"
529
+ return (
530
+ "no importer built into the engine takes ."
531
+ + extension
532
+ + " files; one added by a plugin or an extension is known only to the editor, whose scan imports them"
533
+ )
534
+
535
+
419
536
  static func _stale(reason: String) -> Dictionary:
420
537
  return {"status": "needs_reimport", "reason": reason}
421
538
 
@@ -443,6 +560,27 @@ func _sidecar_staleness(resource_path: String, import_file_path: String, sidecar
443
560
  ## Whether the import itself is still the one its sidecar and source describe: an answer for a
444
561
  ## stale resource, empty for a current one.
445
562
  static func _import_staleness(resource_path: String, sidecar: ConfigFile) -> Dictionary:
563
+ # The importer is asked first, as the editor asks it: whether it still writes this format, and
564
+ # whether the settings the import was made under still hold. Both are true of an import whose
565
+ # files are all current, so they were answered as up to date while the editor's next scan
566
+ # imported them again: every scene after an engine upgrade raised the importer's version, and
567
+ # every VRAM texture once the project asked for another compression format.
568
+ var importer: String = str(sidecar.get_value("remap", "importer", ""))
569
+ var version: int = Read.as_int(sidecar.get_value("remap", "importer_version", 0))
570
+ var current: int = Read.as_int(IMPORTER_VERSIONS.get(importer, 0))
571
+ if current > version:
572
+ return _stale(
573
+ (
574
+ "it was imported at version %d of the %s importer, and this engine imports at version %d"
575
+ % [version, importer, current]
576
+ )
577
+ )
578
+ var lacking: Array[String] = _vram_formats_lacking(sidecar.get_value("remap", "metadata", {}))
579
+ if not lacking.is_empty():
580
+ return _stale(
581
+ "the project now asks for " + ", ".join(lacking) + " textures, and this import did not write them"
582
+ )
583
+
446
584
  if not sidecar.has_section_key("remap", "uid"):
447
585
  return _stale("its import file has no uid, which the import writes")
448
586
 
@@ -505,6 +643,28 @@ static func _import_staleness(resource_path: String, sidecar: ConfigFile) -> Dic
505
643
  return built
506
644
 
507
645
 
646
+ ## The VRAM formats the project asks textures to be imported in that an import recorded in
647
+ ## [param metadata] did not write, as the texture importers judge whether their settings still hold.
648
+ ## Measured on 4.7.2 for the texture and texture array importers: turning a format on reimports every
649
+ ## VRAM texture without it, and turning one off reimports nothing.
650
+ static func _vram_formats_lacking(metadata: Variant) -> Array[String]:
651
+ var lacking: Array[String] = []
652
+ if not metadata is Dictionary:
653
+ return lacking
654
+ var meta: Dictionary = metadata
655
+ if not Read.as_bool(meta.get("vram_texture", false)):
656
+ return lacking
657
+ var written: Variant = meta.get("imported_formats", [])
658
+ var formats: Array = written if written is Array else []
659
+ for format: String in VRAM_FORMATS:
660
+ var asked: bool = Read.as_bool(
661
+ ProjectSettings.get_setting("rendering/textures/vram_compression/import_" + format, false)
662
+ )
663
+ if asked and format not in formats:
664
+ lacking.append(format)
665
+ return lacking
666
+
667
+
508
668
  ## The hashes [param resource_path]'s last import recorded, [code]source_md5[/code] of its source
509
669
  ## and [code]dest_md5[/code] of its outputs, empty when there is no record: the editor keeps them
510
670
  ## beside the outputs, under the same base name with [code].md5[/code].
@@ -1,7 +1,7 @@
1
1
  extends RefCounted
2
2
 
3
- # The server reads one JSON object off stdout and everything else as diagnostics, so the
4
- # prefixes are what let a human tell the two apart in a log without parsing it.
3
+ # Everything printed is diagnostics; the answer goes to a file. The server reads `[ERROR]` lines as
4
+ # the operation's own errors, and the prefixes let a human read the rest without parsing it.
5
5
  #
6
6
  # Every module preloads this as `Log`. `Logger` is a native class from Godot 4.5 on, and a
7
7
  # constant of that name is a parse error rather than a shadowing warning.
@@ -24,48 +24,63 @@ func list_plugins(_params: Dictionary) -> Dictionary:
24
24
  "message": "No addons directory found in the project"
25
25
  }
26
26
 
27
- var enabled_plugins: Array[String] = _enabled_plugin_names()
28
- _log.debug("Enabled plugins: " + str(enabled_plugins))
27
+ var enabled_paths: Array[String] = _enabled_paths()
28
+ _log.debug("Enabled plugins: " + str(enabled_paths))
29
29
 
30
30
  var plugins: Array[Dictionary] = []
31
31
  var enabled_count: int = 0
32
-
33
- var dir: DirAccess = DirAccess.open(addons_path)
34
- if dir and dir.list_dir_begin() == OK:
35
- var folder_name: String = dir.get_next()
36
-
37
- while folder_name != "":
38
- if dir.current_is_dir() and not folder_name.begins_with("."):
39
- var plugin_cfg_path: String = addons_path + folder_name + "/plugin.cfg"
40
-
41
- if FileAccess.file_exists(plugin_cfg_path):
42
- var enabled: bool = folder_name in enabled_plugins
43
- var plugin_info: Dictionary = {
44
- "name": folder_name, "path": addons_path + folder_name, "enabled": enabled
45
- }
46
-
47
- var plugin_config: ConfigFile = ConfigFile.new()
48
- if plugin_config.load(plugin_cfg_path) == OK:
49
- plugin_info["display_name"] = plugin_config.get_value("plugin", "name", folder_name)
50
- plugin_info["description"] = plugin_config.get_value("plugin", "description", "")
51
- plugin_info["author"] = plugin_config.get_value("plugin", "author", "")
52
- plugin_info["version"] = plugin_config.get_value("plugin", "version", "")
53
- plugin_info["script"] = plugin_config.get_value("plugin", "script", "")
54
-
55
- plugins.append(plugin_info)
56
- if enabled:
57
- enabled_count += 1
58
-
59
- folder_name = dir.get_next()
60
-
61
- dir.list_dir_end()
62
-
63
- return {
32
+ for folder: String in _plugin_folders(addons_path):
33
+ var plugin_cfg_path: String = folder.path_join("plugin.cfg")
34
+ var name: String = folder.trim_prefix(addons_path)
35
+ var enabled: bool = plugin_cfg_path in enabled_paths
36
+ var plugin_info: Dictionary = {"name": name, "path": folder, "enabled": enabled}
37
+
38
+ var plugin_config: ConfigFile = ConfigFile.new()
39
+ if plugin_config.load(plugin_cfg_path) == OK:
40
+ plugin_info["display_name"] = plugin_config.get_value("plugin", "name", name)
41
+ plugin_info["description"] = plugin_config.get_value("plugin", "description", "")
42
+ plugin_info["author"] = plugin_config.get_value("plugin", "author", "")
43
+ plugin_info["version"] = plugin_config.get_value("plugin", "version", "")
44
+ plugin_info["script"] = plugin_config.get_value("plugin", "script", "")
45
+
46
+ plugins.append(plugin_info)
47
+ if enabled:
48
+ enabled_count += 1
49
+
50
+ var answer: Dictionary = {
64
51
  "plugins": plugins,
65
52
  "addons_directory_exists": true,
66
53
  "enabled_count": enabled_count,
67
54
  "disabled_count": plugins.size() - enabled_count
68
55
  }
56
+ # Enabled in the project with no plugin.cfg where the entry points: the editor reports each one
57
+ # as an error at every start and loads nothing for it.
58
+ var missing: Array[String] = []
59
+ for path: String in enabled_paths:
60
+ if not FileAccess.file_exists(path):
61
+ missing.append(path)
62
+ if not missing.is_empty():
63
+ answer["enabled_but_missing"] = missing
64
+ return answer
65
+
66
+
67
+ # Every folder under [param root] holding a plugin.cfg, at any depth, the way the editor's plugin
68
+ # list finds them: a folder with one is a plugin and is not looked inside, and one without is
69
+ # searched. Only the top level was read, so a plugin one folder down was never listed.
70
+ func _plugin_folders(root: String) -> Array[String]:
71
+ var found: Array[String] = []
72
+ var dir: DirAccess = DirAccess.open(root)
73
+ if dir == null:
74
+ return found
75
+ for folder_name: String in dir.get_directories():
76
+ if folder_name.begins_with("."):
77
+ continue
78
+ var folder: String = root.path_join(folder_name)
79
+ if FileAccess.file_exists(folder.path_join("plugin.cfg")):
80
+ found.append(folder)
81
+ else:
82
+ found.append_array(_plugin_folders(folder))
83
+ return found
69
84
 
70
85
 
71
86
  # Enable a plugin
@@ -79,19 +94,19 @@ func enable_plugin(params: Dictionary) -> Dictionary:
79
94
  _log.error("Plugin not found: " + plugin_name)
80
95
  return _log.failure("Expected plugin.cfg at: " + plugin_cfg_path)
81
96
 
82
- var enabled_plugins: Array[String] = _enabled_plugin_names()
97
+ var enabled_paths: Array[String] = _enabled_paths()
83
98
 
84
- if plugin_name in enabled_plugins:
99
+ if plugin_cfg_path in enabled_paths:
85
100
  return {
86
101
  "plugin_name": plugin_name, "action": "already_enabled", "message": "Plugin is already enabled"
87
102
  }
88
103
 
89
- enabled_plugins.append(plugin_name)
90
- var err: Error = _save_enabled_plugins(enabled_plugins)
104
+ enabled_paths.append(plugin_cfg_path)
105
+ var err: Error = _save_enabled_paths(enabled_paths)
91
106
  if err != OK:
92
107
  return _log.failure("Failed to save project.godot: " + error_string(err))
93
108
 
94
- return {"plugin_name": plugin_name, "action": "enabled", "enabled_plugins": enabled_plugins}
109
+ return {"plugin_name": plugin_name, "action": "enabled", "enabled_plugins": _names_of(enabled_paths)}
95
110
 
96
111
 
97
112
  # Disable a plugin
@@ -100,42 +115,50 @@ func disable_plugin(params: Dictionary) -> Dictionary:
100
115
 
101
116
  _log.info("Disabling plugin: " + plugin_name)
102
117
 
103
- var enabled_plugins: Array[String] = _enabled_plugin_names()
118
+ var enabled_paths: Array[String] = _enabled_paths()
119
+ var plugin_cfg_path: String = "res://addons/" + plugin_name + "/plugin.cfg"
104
120
 
105
- if not plugin_name in enabled_plugins:
121
+ if not plugin_cfg_path in enabled_paths:
106
122
  return {
107
123
  "plugin_name": plugin_name,
108
124
  "action": "already_disabled",
109
125
  "message": "Plugin is not currently enabled"
110
126
  }
111
127
 
112
- enabled_plugins.erase(plugin_name)
113
- var err: Error = _save_enabled_plugins(enabled_plugins)
128
+ enabled_paths.erase(plugin_cfg_path)
129
+ var err: Error = _save_enabled_paths(enabled_paths)
114
130
  if err != OK:
115
131
  return _log.failure("Failed to save project.godot: " + error_string(err))
116
132
 
117
- return {"plugin_name": plugin_name, "action": "disabled", "enabled_plugins": enabled_plugins}
133
+ return {"plugin_name": plugin_name, "action": "disabled", "enabled_plugins": _names_of(enabled_paths)}
118
134
 
119
135
 
120
- # The addon directory names in [editor_plugins]. The editor stores them as the engine
121
- # expression PackedStringArray("res://addons/<name>/plugin.cfg", ...), which loads as that
122
- # type; the String form is what an older hand-edited file can carry.
123
- func _enabled_plugin_names() -> Array[String]:
124
- var names: Array[String] = []
125
- var regex: RegEx = Patterns.compiled("res://addons/([^/]+)/plugin.cfg")
126
-
136
+ # Every entry in [editor_plugins], as the plugin.cfg path the editor stores. Paths rather than
137
+ # folder names, because the list is written back whole: reading names off a pattern for
138
+ # `res://addons/<name>/plugin.cfg` dropped a plugin one folder down, and the next enable or disable
139
+ # wrote the list without it, disabling a plugin nobody named. The editor stores the engine
140
+ # expression PackedStringArray(...), which loads as that type; the String form is what an older
141
+ # hand-edited file can carry.
142
+ func _enabled_paths() -> Array[String]:
143
+ var paths: Array[String] = []
127
144
  var enabled_value: Variant = ProjectSettings.get_setting("editor_plugins/enabled", PackedStringArray())
128
145
  if enabled_value is PackedStringArray:
129
- var paths: PackedStringArray = enabled_value
130
- for path: String in paths:
131
- var m: RegExMatch = regex.search(path)
132
- if m:
133
- names.append(m.get_string(1))
146
+ var packed: PackedStringArray = enabled_value
147
+ paths.assign(packed)
134
148
  elif enabled_value is String:
135
149
  var listed: String = enabled_value
136
- for m: RegExMatch in regex.search_all(listed):
137
- names.append(m.get_string(1))
150
+ for m: RegExMatch in Patterns.compiled("res://[^\"',)]+?plugin\\.cfg").search_all(listed):
151
+ paths.append(m.get_string())
152
+ return paths
138
153
 
154
+
155
+ # The names callers use for [param paths]: the folder under addons/, or the path itself for an
156
+ # entry somewhere else.
157
+ static func _names_of(paths: Array[String]) -> Array[String]:
158
+ var names: Array[String] = []
159
+ for path: String in paths:
160
+ var inside: bool = path.begins_with("res://addons/") and path.ends_with("/plugin.cfg")
161
+ names.append(path.trim_prefix("res://addons/").trim_suffix("/plugin.cfg") if inside else path)
139
162
  return names
140
163
 
141
164
 
@@ -145,19 +168,14 @@ func _enabled_plugin_names() -> Array[String]:
145
168
  # the unquoted expression the editor reads, whereas the same text handed over as a String is
146
169
  # written quoted and loads as a String the editor does not recognise as a plugin list. An
147
170
  # empty list is the setting removed, which is what the editor writes for no plugins.
148
- func _save_enabled_plugins(names: Array[String]) -> Error:
149
- if names.is_empty():
171
+ func _save_enabled_paths(paths: Array[String]) -> Error:
172
+ if paths.is_empty():
150
173
  ProjectSettings.set_setting("editor_plugins/enabled", null)
151
174
  return ProjectSettings.save()
152
175
 
153
- # Gathered in an Array[String] and converted at the end, because appending to a packed array
154
- # answers with whether it worked and a project holding return_value_discarded at error level
155
- # will not compile a script that drops that answer. The conversion is what matters: an
156
- # Array[String] handed to set_setting is written as Array[String]([...]), which the editor does
157
- # not read as a plugin list at all.
158
- var enabled_paths: Array[String] = []
159
- for name: String in names:
160
- enabled_paths.append("res://addons/" + name + "/plugin.cfg")
161
-
162
- ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(enabled_paths))
176
+ # Kept in an Array[String] and converted here, because appending to a packed array answers with
177
+ # whether it worked and a project holding return_value_discarded at error level will not compile
178
+ # a script that drops that answer. The conversion is what matters: an Array[String] handed to
179
+ # set_setting is written as Array[String]([...]), which the editor does not read as a plugin list.
180
+ ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(paths))
163
181
  return ProjectSettings.save()
@@ -34,6 +34,19 @@ func get_project_setting(params: Dictionary) -> Dictionary:
34
34
  result["value"] = null
35
35
  result["message"] = "Setting does not exist"
36
36
 
37
+ # The value under this name is not the one every build reads: `name.windows` replaces it on
38
+ # Windows, `name.editor` in the editor. Read alone, a caller saw the plain value and not the one
39
+ # their game showed.
40
+ var overrides: Dictionary = {}
41
+ for info: Dictionary in ProjectSettings.get_property_list():
42
+ var name: String = str(info.get("name", ""))
43
+ if name.begins_with(setting_path + "."):
44
+ overrides[name.substr(setting_path.length() + 1)] = _values.serialize_value(
45
+ ProjectSettings.get_setting(name)
46
+ )
47
+ if not overrides.is_empty():
48
+ result["overrides"] = overrides
49
+
37
50
  return result
38
51
 
39
52
 
@@ -139,7 +152,19 @@ func _converts_faithfully(value: Variant, to: int) -> bool:
139
152
  func add_autoload(params: Dictionary) -> Dictionary:
140
153
  var name: String = str(params.get("name", ""))
141
154
  var path: String = str(params.get("path", ""))
142
- var enabled: bool = Read.as_bool(params.get("enabled", true), true)
155
+ var global: bool = Read.as_bool(params.get("global", true), true)
156
+
157
+ # There is no disabled autoload in Godot 4: every entry in the list is loaded under the root at
158
+ # every start. `enabled: false` used to write the entry without its asterisk and answer that it
159
+ # was disabled, and the autoload ran all the same.
160
+ if not Read.as_bool(params.get("enabled", true), true):
161
+ return _log.failure(
162
+ (
163
+ "An autoload cannot be added disabled: Godot loads every autoload in the list at every"
164
+ + " start. global: false keeps its name out of the global scope and still loads it;"
165
+ + " remove_autoload is what stops it loading"
166
+ )
167
+ )
143
168
 
144
169
  if not path.begins_with("res://"):
145
170
  path = "res://" + path
@@ -154,14 +179,21 @@ func add_autoload(params: Dictionary) -> Dictionary:
154
179
  var setting: String = "autoload/" + name
155
180
  var was_updated: bool = ProjectSettings.has_setting(setting)
156
181
 
157
- # An asterisk in front of the path makes the autoload a global name as well. Without it the
158
- # node still comes up under the root; only the name stops resolving.
159
- ProjectSettings.set_setting(setting, ("*" if enabled else "") + path)
182
+ # An asterisk in front of the path makes the autoload's name a global variable, the editor's
183
+ # "Global Variable" column. Without it the node still comes up under the root; only the name
184
+ # stops resolving.
185
+ ProjectSettings.set_setting(setting, ("*" if global else "") + path)
160
186
  var err: Error = ProjectSettings.save()
161
187
  if err != OK:
162
188
  return _log.failure("Failed to save project.godot: " + error_string(err))
163
189
 
164
- return {"name": name, "path": path, "enabled": enabled, "action": "updated" if was_updated else "added"}
190
+ return {
191
+ "name": name,
192
+ "path": path,
193
+ "enabled": true,
194
+ "global": global,
195
+ "action": "updated" if was_updated else "added",
196
+ }
165
197
 
166
198
 
167
199
  # Remove an autoload singleton
@@ -196,10 +228,13 @@ func list_autoloads(_params: Dictionary) -> Dictionary:
196
228
  var value: String = str(ProjectSettings.get_setting(setting))
197
229
  var path: String = value.trim_prefix("*")
198
230
 
231
+ # Enabled whatever the asterisk says, because every autoload listed is loaded; the asterisk
232
+ # is whether its name is a global variable.
199
233
  var autoload_info: Dictionary = {
200
234
  "name": setting.trim_prefix("autoload/"),
201
235
  "path": path,
202
- "enabled": value.begins_with("*"),
236
+ "enabled": true,
237
+ "global": value.begins_with("*"),
203
238
  "file_exists": FileAccess.file_exists(path),
204
239
  }
205
240
  autoloads.append(autoload_info)