gdharness 1.2.14 → 1.2.16

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.
@@ -17,10 +17,6 @@ const Targets = preload("runtime_targets.gd")
17
17
  ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
18
18
  const TO_SMALL: int = 32
19
19
 
20
- ## What a game started without a window gets whatever the project settings say, and the usual
21
- ## reason a control is out of reach. Only worth telling somebody when it is what they have.
22
- const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
23
-
24
20
  ## Set, to the desktop's name, for a process gdharness started on a desktop of its own and for
25
21
  ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
26
22
  const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
@@ -654,20 +650,8 @@ func _out_of_reach(
654
650
  )
655
651
 
656
652
 
657
- ## What to add to a refusal about a point outside the viewport, when the reason is that nobody
658
- ## gave this game a window. The rect on its own does not say it, and it is the usual reason.
659
- ##
660
- ## The size is only claimed where it is true: the rect is printed beside this, so naming 64 by 64
661
- ## over a viewport somebody has resized says two different things in one sentence.
662
653
  func _no_window_note(viewport: Viewport) -> String:
663
- if viewport != _host.get_tree().root or _host.get_tree().root.can_draw():
664
- return ""
665
- if viewport.get_visible_rect().size == HEADLESS_VIEWPORT:
666
- return (
667
- ". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
668
- + "the project settings say: run it with a window to reach this control"
669
- )
670
- return ". This game has no window: run it with a window to reach this control"
654
+ return Targets.no_window_note(viewport, _host.get_tree().root)
671
655
 
672
656
 
673
657
  ## Sends a click's press or release into [param viewport], through Input when that is the game's
@@ -12,6 +12,10 @@ const Words = preload("runtime_words.gd")
12
12
  ## How many of the controls saying the same words a refusal names, with the index for each.
13
13
  const LISTED_CANDIDATES: int = 10
14
14
 
15
+ ## What a game started without a window gets whatever the project settings say, and the usual
16
+ ## reason a control is out of reach. Only worth telling somebody when it is what they have.
17
+ const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
18
+
15
19
  ## What a match's rank is made of, one bit each, the highest deciding first.
16
20
  const TAKES_POINTER: int = 4
17
21
  const ENABLED_BUTTON: int = 2
@@ -139,7 +143,7 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
139
143
  under,
140
144
  wanted,
141
145
  _hidden_note(hidden),
142
- _outside_note(outside),
146
+ _outside_note(outside, root),
143
147
  _covered_note(covered, cover),
144
148
  _open_menu_note(root, wanted),
145
149
  ]
@@ -393,10 +397,32 @@ static func _hidden_note(hidden: int) -> String:
393
397
 
394
398
  ## What to add to a refusal that found nothing on screen, when words matched controls a click cannot
395
399
  ## reach: off the viewport, or clipped away by a container it does not scroll.
396
- static func _outside_note(outside: int) -> String:
400
+ static func _outside_note(outside: int, root: Node) -> String:
397
401
  if outside == 0:
398
402
  return ""
399
- return "; %d control%s it outside what the screen shows" % [outside, " says" if outside == 1 else "s say"]
403
+ var note: String = (
404
+ "; %d control%s it outside what the screen shows" % [outside, " says" if outside == 1 else "s say"]
405
+ )
406
+ var window: Window = root as Window
407
+ return note if window == null else note + no_window_note(window, window)
408
+
409
+
410
+ ## What to add to a refusal about a point outside the viewport, when the reason is that nobody
411
+ ## gave this game a window. The rect on its own does not say it, and it is the usual reason: a
412
+ ## headless game's button twenty pixels in from the corner was refused as off screen, and the
413
+ ## refusal read as though the button were not there.
414
+ ##
415
+ ## The size is only claimed where it is true: the rect is printed beside this, so naming 64 by 64
416
+ ## over a viewport somebody has resized says two different things in one sentence.
417
+ static func no_window_note(viewport: Viewport, root: Window) -> String:
418
+ if viewport != root or root.can_draw():
419
+ return ""
420
+ if viewport.get_visible_rect().size == HEADLESS_VIEWPORT:
421
+ return (
422
+ ". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
423
+ + "the project settings say: run it with a window to reach this control"
424
+ )
425
+ return ". This game has no window: run it with a window to reach this control"
400
426
 
401
427
 
402
428
  ## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
@@ -540,6 +540,9 @@ func _serialize_colors(value: PackedColorArray) -> Array:
540
540
 
541
541
  # A pathless Resource says so rather than inventing an empty path for the caller to load.
542
542
  func _serialize_object(value: Object) -> Variant:
543
+ if value is InputEvent:
544
+ var event: InputEvent = value
545
+ return _describe_input_event(event)
543
546
  if value is Resource:
544
547
  var resource: Resource = value
545
548
  if resource.resource_path.is_empty():
@@ -552,6 +555,23 @@ func _serialize_object(value: Object) -> Variant:
552
555
  return _describe_object(value)
553
556
 
554
557
 
558
+ ## An input event, by what it is bound to: the engine's own words for it and every field it stores
559
+ ## away from its default. As a bare resource it was its class and nothing else, so an input action
560
+ ## read back from project.godot could not say whether it was bound to Space or to a joypad button.
561
+ func _describe_input_event(event: InputEvent) -> Dictionary:
562
+ var described: Dictionary = {"class": event.get_class(), "_type": "Resource", "text": event.as_text()}
563
+ for property: Dictionary in event.get_property_list():
564
+ var usage: int = property.get("usage", 0)
565
+ var name: String = str(property.get("name", ""))
566
+ if not (usage & PROPERTY_USAGE_STORAGE) or name.begins_with("resource_") or name == "script":
567
+ continue
568
+ var held: Variant = event.get(name)
569
+ if held == ClassDB.class_get_property_default_value(event.get_class(), name):
570
+ continue
571
+ described[name] = serialize_value(held)
572
+ return described
573
+
574
+
555
575
  ## A plain object, said in enough detail to tell it from the one beside it.
556
576
  ##
557
577
  ## `{"class": "RefCounted"}` was all of it, so a roster of twelve people came back as the word
@@ -44,6 +44,41 @@ func create_audio_bus(params: Dictionary) -> Dictionary:
44
44
  return {"bus": _bus(new_idx), "buses": buses, "layout": layout}
45
45
 
46
46
 
47
+ func remove_audio_bus(params: Dictionary) -> Dictionary:
48
+ var bus_name: String = str(params.get("bus_name", ""))
49
+ if bus_name.is_empty():
50
+ return _log.failure("bus_name is required")
51
+ var index: int = AudioServer.get_bus_index(bus_name)
52
+ if index == -1:
53
+ var names: Array[String] = []
54
+ for i: int in range(AudioServer.bus_count):
55
+ names.append(AudioServer.get_bus_name(i))
56
+ return _log.failure("No bus named %s. The buses are %s." % [bus_name, ", ".join(names)])
57
+ if index == 0:
58
+ return _log.failure("%s is the bus every other ends in, so it cannot be removed." % bus_name)
59
+
60
+ # A bus sending to this one would be left sending to a name nothing has, which the engine reads
61
+ # as Master; sent on to where this one went instead, the chain it was part of keeps its shape.
62
+ var onward: String = AudioServer.get_bus_send(index)
63
+ var resent: Array[String] = []
64
+ for i: int in range(AudioServer.bus_count):
65
+ if i != index and AudioServer.get_bus_send(i) == bus_name:
66
+ AudioServer.set_bus_send(i, onward)
67
+ resent.append(AudioServer.get_bus_name(i))
68
+ AudioServer.remove_bus(index)
69
+
70
+ var layout: String = _save_layout()
71
+ if layout.is_empty():
72
+ return {}
73
+ var buses: Array[Dictionary] = []
74
+ for i: int in range(AudioServer.bus_count):
75
+ buses.append(_bus(i))
76
+ var answer: Dictionary = {"removed": bus_name, "buses": buses, "layout": layout}
77
+ if not resent.is_empty():
78
+ answer["resent"] = {"buses": resent, "to": onward}
79
+ return answer
80
+
81
+
47
82
  func get_audio_buses(_params: Dictionary) -> Dictionary:
48
83
  var buses: Array[Dictionary] = []
49
84
  for i: int in range(AudioServer.bus_count):
@@ -101,7 +101,7 @@ func query_class_info(params: Dictionary) -> Dictionary:
101
101
  )
102
102
 
103
103
  if not ClassDB.class_exists(class_name_str):
104
- return _log.failure("Class not found: " + class_name_str)
104
+ return _not_found(class_name_str)
105
105
 
106
106
  var member: String = str(params.get("member", ""))
107
107
  if not member.is_empty():
@@ -152,6 +152,35 @@ func query_class_info(params: Dictionary) -> Dictionary:
152
152
  # is as often an ancestor's as the class's own, so the ancestry is walked without being asked. Each
153
153
  # match says which class declares it. Nothing found names the members whose names contain the one
154
154
  # asked for, since the usual miss is a spelling.
155
+ ## Why [param wanted] has no answer here. A class_name a project script declares is not in ClassDB,
156
+ ## and "Class not found" for one read as though the project had no such class.
157
+ func _not_found(wanted: String) -> Dictionary:
158
+ for entry: Dictionary in ProjectSettings.get_global_class_list():
159
+ if str(entry.get("class", "")) == wanted:
160
+ return (
161
+ _log
162
+ . failure(
163
+ (
164
+ (
165
+ "%s is not an engine class: it is the class_name %s declares, and editor_classes reads "
166
+ + "the engine's ClassDB only. script_info structure on that script, with "
167
+ + "includeInherited, answers what it declares and what it inherits."
168
+ )
169
+ % [wanted, str(entry.get("path", ""))]
170
+ )
171
+ )
172
+ )
173
+ return _log.failure(
174
+ (
175
+ (
176
+ "Class not found: %s. editor_classes reads the engine's ClassDB, so a class_name a project "
177
+ + "script declares is not in it either; script_info structure reads one."
178
+ )
179
+ % wanted
180
+ )
181
+ )
182
+
183
+
155
184
  func _member_of(class_name_str: String, member: String) -> Dictionary:
156
185
  var found: Array[Dictionary] = []
157
186
  var current: String = class_name_str
@@ -223,7 +252,7 @@ func inspect_inheritance(params: Dictionary) -> Dictionary:
223
252
  _log.info("Inspecting inheritance for: " + class_name_str)
224
253
 
225
254
  if not ClassDB.class_exists(class_name_str):
226
- return _log.failure("Class not found: " + class_name_str)
255
+ return _not_found(class_name_str)
227
256
 
228
257
  var ancestors: Array[String] = []
229
258
  var current: String = class_name_str
@@ -35,10 +35,17 @@ func is_stepped_over(path: String) -> bool:
35
35
  return FileAccess.file_exists(path + ".gdignore")
36
36
 
37
37
 
38
+ # An addon gdharness installed, told by the marker setup writes into it. The engine loads it, but
39
+ # nobody on the project wrote it: the health check counted its thirty-odd scripts as the project's,
40
+ # 39 beside the 5 project_info's own count gave, which the server's walks already step over.
41
+ func is_installed_by_gdharness(path: String) -> bool:
42
+ return FileAccess.file_exists(path + ".gdharness-version")
43
+
44
+
38
45
  # Files under `path` whose name ends with `extension`, which is passed with its dot.
39
46
  func find_files(path: String, extension: String) -> Array[String]:
40
47
  var files: Array[String] = []
41
- if is_stepped_over(path):
48
+ if is_stepped_over(path) or is_installed_by_gdharness(path):
42
49
  return files
43
50
 
44
51
  var dir: DirAccess = DirAccess.open(path)
@@ -62,7 +69,7 @@ func find_files(path: String, extension: String) -> Array[String]:
62
69
  # Files under `path` whose extension is in `extensions`, which are passed without their dot.
63
70
  func find_files_with_extensions(path: String, extensions: Array) -> Array[String]:
64
71
  var files: Array[String] = []
65
- if is_stepped_over(path):
72
+ if is_stepped_over(path) or is_installed_by_gdharness(path):
66
73
  return files
67
74
 
68
75
  var dir: DirAccess = DirAccess.open(path)
@@ -63,12 +63,11 @@ func create_gdscript(params: Dictionary) -> Dictionary:
63
63
  if content.is_empty():
64
64
  if not cls_name_param.is_empty():
65
65
  script_content += "class_name " + cls_name_param + "\n"
66
- script_content += "extends " + extends_class + "\n\n"
66
+ script_content += "extends " + extends_class + "\n"
67
+ # Only what was asked for: with no template the file was given an empty _ready beside a
68
+ # template_used of "none", which a caller had then to delete or leave lying in the script.
67
69
  if not template.is_empty():
68
- script_content += _script_template(template)
69
- else:
70
- script_content += "func _ready() -> void:\n"
71
- script_content += "\tpass\n"
70
+ script_content += "\n" + _script_template(template)
72
71
 
73
72
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.WRITE)
74
73
  if not file:
@@ -186,10 +186,14 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
186
186
  payload = Plugins.new(_log).disable_plugin(params)
187
187
  "add_input_action":
188
188
  payload = InputActions.new(_log).add_input_action(params)
189
+ "remove_input_action":
190
+ payload = InputActions.new(_log).remove_input_action(params)
189
191
 
190
192
  # Audio
191
193
  "create_audio_bus":
192
194
  payload = AudioBuses.new(_log).create_audio_bus(params)
195
+ "remove_audio_bus":
196
+ payload = AudioBuses.new(_log).remove_audio_bus(params)
193
197
  "get_audio_buses":
194
198
  payload = AudioBuses.new(_log).get_audio_buses(params)
195
199
  "set_audio_bus_effect":
@@ -184,6 +184,33 @@ func add_input_action(params: Dictionary) -> Dictionary:
184
184
  }
185
185
 
186
186
 
187
+ # Remove an input action the project defines, which add_input_action had no way back from.
188
+ func remove_input_action(params: Dictionary) -> Dictionary:
189
+ var action_name: String = str(params.get("action_name", ""))
190
+ if action_name.is_empty():
191
+ return _log.failure("action_name is required")
192
+ var setting: String = "input/" + action_name
193
+ if not ProjectSettings.has_setting(setting):
194
+ return _log.failure("This project has no input action named " + action_name)
195
+ # The engine registers its own ui_ actions with a default, so clearing one from project.godot
196
+ # puts the engine's binding back rather than removing the action.
197
+ if ProjectSettings.property_get_revert(setting) != null:
198
+ return _log.failure(
199
+ (
200
+ (
201
+ "%s is one of the engine's own actions: removing it from project.godot restores the "
202
+ + "engine's binding rather than removing it. Rebind it with add_input_action instead."
203
+ )
204
+ % action_name
205
+ )
206
+ )
207
+ ProjectSettings.set_setting(setting, null)
208
+ var err: Error = ProjectSettings.save()
209
+ if err != OK:
210
+ return _log.failure("Failed to save project.godot: " + error_string(err))
211
+ return {"action_name": action_name, "removed": true}
212
+
213
+
187
214
  # The value project.godot stores for one input action.
188
215
  #
189
216
  # It has to be a Dictionary holding real InputEvent objects. The engine writes those as the
@@ -29,7 +29,14 @@ func get_project_setting(params: Dictionary) -> Dictionary:
29
29
  }
30
30
 
31
31
  if result["exists"]:
32
- result["value"] = _values.serialize_value(ProjectSettings.get_setting(setting_path))
32
+ var value: Variant = ProjectSettings.get_setting(setting_path)
33
+ result["value"] = _values.serialize_value(value)
34
+ # A setting the engine registers exists whether or not the project sets it, so exists did
35
+ # not say whether project.godot has a line for it. At its default it has none: the engine
36
+ # drops such a key when it saves.
37
+ var default_value: Variant = ProjectSettings.property_get_revert(setting_path)
38
+ if default_value != null:
39
+ result["at_default"] = value == default_value
33
40
  else:
34
41
  result["value"] = null
35
42
  result["message"] = "Setting does not exist"
@@ -1,6 +1,7 @@
1
1
  extends RefCounted
2
2
 
3
3
  const FileWalk = preload("file_walk.gd")
4
+ const ImportPipeline = preload("import_pipeline.gd")
4
5
  const Log = preload("logger.gd")
5
6
 
6
7
  var _log: Log
@@ -40,10 +41,11 @@ func get_project_health(params: Dictionary) -> Dictionary:
40
41
  if "resources" in check_categories:
41
42
  var details: Array[String] = []
42
43
 
43
- var importable_extensions: Array[String] = ["png", "jpg", "wav", "mp3", "ogg"]
44
- var resources: Array[String] = []
45
- for ext: String in importable_extensions:
46
- resources.append_array(_files.find_files("res://", "." + ext))
44
+ # Every format the engine imports, the list the import status reads: five of them were checked
45
+ # here, so a project of glTF models, fonts and WebP textures had nothing to reimport.
46
+ var resources: Array[String] = _files.find_files_with_extensions(
47
+ "res://", ImportPipeline.IMPORTABLE_EXTENSIONS
48
+ )
47
49
 
48
50
  var missing_imports: int = 0
49
51
  for res: String in resources:
@@ -58,7 +60,9 @@ func get_project_health(params: Dictionary) -> Dictionary:
58
60
  "name": "Resources",
59
61
  "passed": details.is_empty(),
60
62
  "details": details,
61
- "total_resources": resources.size()
63
+ # What the check read: files the engine imports. A .tres is a resource and was counted
64
+ # under this name as none, beside a project holding one.
65
+ "importable_files": resources.size()
62
66
  }
63
67
 
64
68
  # 3. Scripts Check
@@ -540,6 +540,9 @@ func _serialize_colors(value: PackedColorArray) -> Array:
540
540
 
541
541
  # A pathless Resource says so rather than inventing an empty path for the caller to load.
542
542
  func _serialize_object(value: Object) -> Variant:
543
+ if value is InputEvent:
544
+ var event: InputEvent = value
545
+ return _describe_input_event(event)
543
546
  if value is Resource:
544
547
  var resource: Resource = value
545
548
  if resource.resource_path.is_empty():
@@ -552,6 +555,23 @@ func _serialize_object(value: Object) -> Variant:
552
555
  return _describe_object(value)
553
556
 
554
557
 
558
+ ## An input event, by what it is bound to: the engine's own words for it and every field it stores
559
+ ## away from its default. As a bare resource it was its class and nothing else, so an input action
560
+ ## read back from project.godot could not say whether it was bound to Space or to a joypad button.
561
+ func _describe_input_event(event: InputEvent) -> Dictionary:
562
+ var described: Dictionary = {"class": event.get_class(), "_type": "Resource", "text": event.as_text()}
563
+ for property: Dictionary in event.get_property_list():
564
+ var usage: int = property.get("usage", 0)
565
+ var name: String = str(property.get("name", ""))
566
+ if not (usage & PROPERTY_USAGE_STORAGE) or name.begins_with("resource_") or name == "script":
567
+ continue
568
+ var held: Variant = event.get(name)
569
+ if held == ClassDB.class_get_property_default_value(event.get_class(), name):
570
+ continue
571
+ described[name] = serialize_value(held)
572
+ return described
573
+
574
+
555
575
  ## A plain object, said in enough detail to tell it from the one beside it.
556
576
  ##
557
577
  ## `{"class": "RefCounted"}` was all of it, so a roster of twelve people came back as the word