gdharness 1.3.8 → 1.3.10

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 (78) hide show
  1. package/build/cli.js +4668 -3229
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +115 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +112 -6
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +19 -29
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +39 -6
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
  17. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  20. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  23. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
  25. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  26. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
  27. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  28. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  29. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
  30. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  31. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
  32. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  33. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  34. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  35. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  36. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  37. package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
  38. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  40. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
  41. package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
  42. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  43. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  44. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
  45. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
  46. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  47. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
  48. package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
  49. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  50. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  51. package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
  52. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  53. package/build/godot/operations/animation_reads.gd +115 -4
  54. package/build/godot/operations/audio_buses.gd +93 -7
  55. package/build/godot/operations/classdb_queries.gd +119 -7
  56. package/build/godot/operations/declared_values.gd +272 -0
  57. package/build/godot/operations/dependencies.gd +154 -41
  58. package/build/godot/operations/editor_tools.gd +29 -13
  59. package/build/godot/operations/export_presets.gd +602 -4
  60. package/build/godot/operations/file_reads.gd +115 -18
  61. package/build/godot/operations/gdscript_analysis.gd +14 -0
  62. package/build/godot/operations/gdscript_authoring.gd +12 -2
  63. package/build/godot/operations/godot_operations.gd +18 -2
  64. package/build/godot/operations/import_pipeline.gd +20 -221
  65. package/build/godot/operations/input_actions.gd +127 -51
  66. package/build/godot/operations/project_config.gd +113 -68
  67. package/build/godot/operations/project_diagnostics.gd +4 -0
  68. package/build/godot/operations/project_validation.gd +266 -0
  69. package/build/godot/operations/property_paths.gd +89 -16
  70. package/build/godot/operations/property_values.gd +180 -121
  71. package/build/godot/operations/reading.gd +112 -6
  72. package/build/godot/operations/scene_connections.gd +222 -0
  73. package/build/godot/operations/serialisation.gd +19 -29
  74. package/build/godot/operations/setting_reads.gd +121 -0
  75. package/build/godot/operations/value_shapes.gd +128 -1
  76. package/build/godot/tests/listed.gd +20 -13
  77. package/build/keeper.js +1402 -707
  78. package/package.json +1 -1
@@ -6,6 +6,36 @@ extends RefCounted
6
6
 
7
7
  const GdhRead = preload("reading.gd")
8
8
 
9
+ ## The section of layer_names in project.godot naming the layers of each layers hint.
10
+ const LAYER_NAMES: Dictionary[int, String] = {
11
+ PROPERTY_HINT_LAYERS_2D_PHYSICS: "2d_physics",
12
+ PROPERTY_HINT_LAYERS_2D_RENDER: "2d_render",
13
+ PROPERTY_HINT_LAYERS_2D_NAVIGATION: "2d_navigation",
14
+ PROPERTY_HINT_LAYERS_3D_PHYSICS: "3d_physics",
15
+ PROPERTY_HINT_LAYERS_3D_RENDER: "3d_render",
16
+ PROPERTY_HINT_LAYERS_3D_NAVIGATION: "3d_navigation",
17
+ PROPERTY_HINT_LAYERS_AVOIDANCE: "avoidance",
18
+ }
19
+ ## How many layers a mask holds, one bit each, whatever the editor names.
20
+ const MASK_LAYERS: int = 32
21
+
22
+
23
+ ## The layers [param mask] holds under a layers [param hint], each with the name project.godot
24
+ ## gives it where it gives one.
25
+ static func layers_in(hint: int, mask: int) -> Array[Dictionary]:
26
+ var layers: Array[Dictionary] = []
27
+ for layer: int in range(1, MASK_LAYERS + 1):
28
+ if mask & (1 << (layer - 1)) == 0:
29
+ continue
30
+ var entry: Dictionary = {"layer": layer}
31
+ var name: String = str(
32
+ ProjectSettings.get_setting("layer_names/%s/layer_%d" % [LAYER_NAMES[hint], layer], "")
33
+ )
34
+ if not name.is_empty():
35
+ entry["name"] = name
36
+ layers.append(entry)
37
+ return layers
38
+
9
39
 
10
40
  ## The values an enum hint names, by name, from its hint string: "SWORD:0,SHIELD:1" as a script
11
41
  ## declares one, or "Add,Subtract" as an engine property does, each name then taking the number
@@ -127,6 +157,77 @@ static func enum_refusal(
127
157
  return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
128
158
 
129
159
 
160
+ ## Why [param word], given as argument [param index] of [param called] for the enum [param named],
161
+ ## names none of its [param choices].
162
+ static func enum_argument_refusal(
163
+ called: String, index: int, named: String, word: String, choices: Dictionary
164
+ ) -> String:
165
+ var said: String = (
166
+ "%s takes the enum %s as argument %d, and %s is none of its values: send %s by name or by number"
167
+ % [called, named, index + 1, word, _listed(choices)]
168
+ )
169
+ var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
170
+ if close.is_empty():
171
+ return said + "."
172
+ return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
173
+
174
+
175
+ ## Why [param value], given as argument [param index] for the enum [param named], is none of its
176
+ ## [param choices], or "" when it is one. The engine passes any number on.
177
+ static func outside_enum_argument(index: int, named: String, value: Variant, choices: Dictionary) -> String:
178
+ if typeof(value) != TYPE_INT or choices.values().has(value):
179
+ return ""
180
+ return (
181
+ "argument %d, %d, is none of the values %s declares: %s."
182
+ % [index + 1, value, named, _listed(choices)]
183
+ )
184
+
185
+
186
+ ## The values of the enum the engine names [param qualified], by name: an engine class's, as
187
+ ## Node.ProcessMode, or a script's, as Named.Size for a class_name, res://main.gd.Mood for a script
188
+ ## without one, and either followed by inner classes. {} when it names nothing this can read.
189
+ static func enum_declared_as(qualified: String) -> Dictionary:
190
+ var cut: int = qualified.rfind(".")
191
+ if cut <= 0:
192
+ return {}
193
+ var owner: String = qualified.substr(0, cut)
194
+ var named: String = qualified.substr(cut + 1)
195
+ if ClassDB.class_exists(owner):
196
+ var constants: Dictionary = {}
197
+ for constant: String in ClassDB.class_get_enum_constants(owner, named):
198
+ constants[constant] = ClassDB.class_get_integer_constant(owner, constant)
199
+ return constants
200
+ var script: Script = _script_named_as(owner)
201
+ if script == null:
202
+ return {}
203
+ var found: Variant = script.get_script_constant_map().get(named)
204
+ if not found is Dictionary:
205
+ return {}
206
+ var values: Dictionary = found
207
+ return values
208
+
209
+
210
+ ## The script [param owner] names: a class_name, a res:// path, or an inner class of either after a
211
+ ## dot, or null.
212
+ static func _script_named_as(owner: String) -> Script:
213
+ var by_name: Script = script_class(owner)
214
+ if by_name != null:
215
+ return by_name
216
+ if owner.begins_with("res://") and ResourceLoader.exists(owner):
217
+ return load(owner) as Script
218
+ var cut: int = owner.rfind(".")
219
+ if cut <= 0:
220
+ return null
221
+ var outer: Script = _script_named_as(owner.substr(0, cut))
222
+ if outer == null:
223
+ return null
224
+ var inner: Variant = outer.get_script_constant_map().get(owner.substr(cut + 1))
225
+ if not inner is Script:
226
+ return null
227
+ var inner_script: Script = inner
228
+ return inner_script
229
+
230
+
130
231
  ## Why [param value], shown as [param shown], lies outside what [param declared] allows, an export
131
232
  ## range or an enum's values, or "" when it does not. A range marked or_greater or or_less is open
132
233
  ## on that side.
@@ -153,6 +254,19 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
153
254
  if choices.is_empty() or choices.values().has(value):
154
255
  return ""
155
256
  return "%d is none of the values %s declares: %s." % [value, property, _listed(choices)]
257
+ if hint == PROPERTY_HINT_FLAGS and typeof(value) == TYPE_INT:
258
+ var flags: Dictionary = flags_of(hint_string)
259
+ var known: int = 0
260
+ for bit: int in flags.values():
261
+ known |= bit
262
+ var whole: int = value
263
+ var stray: int = whole & ~known
264
+ if flags.is_empty() or stray == 0:
265
+ return ""
266
+ return (
267
+ "%d holds bits no flag of %s has (%d): its flags are %s; the engine stores it as given."
268
+ % [whole, property, stray, _listed(flags)]
269
+ )
156
270
  if hint != PROPERTY_HINT_RANGE:
157
271
  return ""
158
272
  var parts: PackedStringArray = []
@@ -173,6 +287,20 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
173
287
  )
174
288
 
175
289
 
290
+ ## The flags a flags hint names, each with its bit: "Fill:1,Expand:2" gives its values, and a name
291
+ ## with none takes the bit of its place, as @export_flags("A", "B") does.
292
+ static func flags_of(hint_string: String) -> Dictionary:
293
+ var flags: Dictionary = {}
294
+ var parts: PackedStringArray = hint_string.split(",", false)
295
+ for index: int in parts.size():
296
+ var part: String = parts[index].strip_edges()
297
+ if ":" in part:
298
+ flags[part.get_slice(":", 0)] = part.get_slice(":", 1).to_int()
299
+ else:
300
+ flags[part] = 1 << index
301
+ return flags
302
+
303
+
176
304
  static func _listed(choices: Dictionary) -> String:
177
305
  var listed: PackedStringArray = []
178
306
  for name: String in choices:
@@ -291,3 +419,147 @@ static func is_a(value: Object, name: String) -> bool:
291
419
  return true
292
420
  script = script.get_base_script()
293
421
  return false
422
+
423
+
424
+ ## Whether writing [param name] on [param target] runs code of its script: the script itself, a
425
+ ## property it declares, or any property once it takes sets through [code]_set[/code].
426
+ static func runs_script_code(target: Object, name: String) -> bool:
427
+ var script: Script = target.get_script()
428
+ if name == "script":
429
+ return true
430
+ if script == null:
431
+ return false
432
+ for method: Dictionary in script.get_script_method_list():
433
+ if str(method.get("name", "")) == "_set":
434
+ return true
435
+ for declared: Dictionary in script.get_script_property_list():
436
+ if str(declared.get("name", "")) == name:
437
+ return true
438
+ return false
439
+
440
+
441
+ ## Why [param property] of a [param holder], an [param engine_class], declared as [param declared],
442
+ ## holds [param held], as shown, after it was given [param given], the value and how it is shown. A
443
+ ## text property with a list of names holds only one of them, and its getter answers its default
444
+ ## for any other. A player's bus is one, though the engine fills its list only in the inspector, so
445
+ ## the buses are read from the AudioServer, which holds the project's layout; "the engine changed or
446
+ ## refused the value" left a caller to guess the bus did not exist.
447
+ static func not_held(
448
+ holder: String, engine_class: String, property: String, declared: Dictionary, given: Array, held: String
449
+ ) -> String:
450
+ var value: Variant = given[0]
451
+ var said: String = "%s.%s was given %s and holds %s after it was set" % [holder, property, given[1], held]
452
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
453
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
454
+ if hint in LAYER_NAMES and typeof(value) == TYPE_INT:
455
+ return (
456
+ (
457
+ "%s: it is a mask of the %s layers, one bit each from layer 1 (1) to layer %d (%d), so it "
458
+ + "takes 0 to %d and the engine keeps only those bits. Nothing was written."
459
+ )
460
+ % [
461
+ said,
462
+ LAYER_NAMES[hint],
463
+ MASK_LAYERS,
464
+ 1 << (MASK_LAYERS - 1),
465
+ (1 << MASK_LAYERS) - 1,
466
+ ]
467
+ )
468
+ var names: PackedStringArray = []
469
+ if hint == PROPERTY_HINT_ENUM and (type == TYPE_STRING or type == TYPE_STRING_NAME):
470
+ names = PackedStringArray(enum_values(str(declared.get("hint_string", ""))).keys())
471
+ var bus: bool = property == "bus" and engine_class.begins_with("AudioStreamPlayer")
472
+ if bus:
473
+ names = []
474
+ for index: int in AudioServer.bus_count:
475
+ var _added: bool = names.append(AudioServer.get_bus_name(index))
476
+ if names.is_empty() or names.has(str(value)):
477
+ return said + ": the engine changed or refused the value, so nothing was written."
478
+ var close: PackedStringArray = GdhRead.nearest(str(value), names)
479
+ var nearest: String = (
480
+ "%s %s" % ["is" if close.size() == 1 else "are", ", ".join(close)] if not close.is_empty() else ""
481
+ )
482
+ if bus:
483
+ # Not "holds Master after it was set": the getter answers the first bus for any name the
484
+ # layout lacks, and nothing was kept.
485
+ return (
486
+ (
487
+ "%s.bus takes the name of one of the project's audio buses, and %s is none of them, so it "
488
+ + "would play on %s; %s. project_info include audio_buses lists them, and "
489
+ + "project_settings add_audio_bus adds one. Nothing was written."
490
+ )
491
+ % [
492
+ holder,
493
+ given[1],
494
+ AudioServer.get_bus_name(0),
495
+ "the buses are " + ", ".join(names) if nearest.is_empty() else "the nearest " + nearest
496
+ ]
497
+ )
498
+ return (
499
+ "%s: it takes one of the names it lists, and %s is none of them; %s. Nothing was written."
500
+ % [
501
+ said,
502
+ given[1],
503
+ "it lists " + ", ".join(names) if nearest.is_empty() else "the nearest it lists " + nearest
504
+ ]
505
+ )
506
+
507
+
508
+ ## The sentence naming the children of [param node] that have [param property], or "" when none
509
+ ## does. A property of a helper node set on the body it serves, as navigation_layers on the
510
+ ## CharacterBody2D rather than its NavigationAgent2D, was refused with the nearest physics layer.
511
+ static func held_by_children(node: Node, property: String) -> String:
512
+ var root: Node = node.owner if node.owner != null else node
513
+ var holders: PackedStringArray = []
514
+ for child: Node in node.get_children():
515
+ if property in child:
516
+ var _added: bool = holders.append("%s (a %s)" % [root.get_path_to(child), child.get_class()])
517
+ if holders.is_empty():
518
+ return ""
519
+ if holders.size() == 1:
520
+ return " The node at %s has it." % holders[0]
521
+ var last: String = holders[holders.size() - 1]
522
+ return " The nodes at %s and %s have it." % [", ".join(holders.slice(0, -1)), last]
523
+
524
+
525
+ ## A sentence for each node path in [param given], by property, that names no node from
526
+ ## [param node]. A path is written whatever it names, as the inspector writes one, since the node may
527
+ ## be added next; a joint whose node_a named nothing then joined nothing, and no answer said so.
528
+ static func naming_nothing(node: Node, given: Dictionary) -> Dictionary:
529
+ var said: Dictionary = {}
530
+ for property: String in given:
531
+ if "/" in property or ":" in property:
532
+ continue
533
+ # What the node holds, since a path given as text is held as text until the engine stores it.
534
+ var path: Variant = node.get(property)
535
+ if not path is NodePath:
536
+ continue
537
+ var named: NodePath = path
538
+ if named.is_empty() or node.get_node_or_null(named) != null:
539
+ continue
540
+ said[property] = (
541
+ (
542
+ "%s names no node from %s. It is written, as the editor writes one, since that node may "
543
+ + "be added next; until it is, %s finds nothing."
544
+ )
545
+ % [str(named), node.name, property]
546
+ )
547
+ return said
548
+
549
+
550
+ ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
551
+ ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
552
+ ## material.
553
+ static func no_uniform(material: ShaderMaterial, property: String) -> String:
554
+ var uniform: String = property.trim_prefix("shader_parameter/")
555
+ if material.shader == null:
556
+ return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
557
+ var shader: String = material.shader.resource_path
558
+ var named: String = shader if not shader.is_empty() else "Its shader"
559
+ var uniforms: Array = []
560
+ for entry: Dictionary in material.shader.get_shader_uniform_list():
561
+ uniforms.append(str(entry["name"]))
562
+ var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
563
+ if uniforms.is_empty():
564
+ return said + "; it declares no uniforms."
565
+ return GdhRead.offered(said, uniform, uniforms) + "."
@@ -24,6 +24,23 @@ extends RefCounted
24
24
 
25
25
  ## How many of a node's children a refusal for a path missing below it names.
26
26
  const NAMED_CHILDREN: int = 12
27
+ ## The types holding their floats as the engine's real numbers, 32-bit unless it was built for 64.
28
+ const SHAPES_OF_REALS: Array[int] = [
29
+ TYPE_VECTOR2,
30
+ TYPE_VECTOR3,
31
+ TYPE_VECTOR4,
32
+ TYPE_RECT2,
33
+ TYPE_PLANE,
34
+ TYPE_QUATERNION,
35
+ TYPE_AABB,
36
+ TYPE_BASIS,
37
+ TYPE_TRANSFORM2D,
38
+ TYPE_TRANSFORM3D,
39
+ TYPE_PROJECTION,
40
+ TYPE_PACKED_VECTOR2_ARRAY,
41
+ TYPE_PACKED_VECTOR3_ARRAY,
42
+ TYPE_PACKED_VECTOR4_ARRAY,
43
+ ]
27
44
 
28
45
 
29
46
  static func as_int(value: Variant, fallback: int = 0) -> int:
@@ -176,7 +193,13 @@ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray
176
193
  func(a: String, b: String) -> bool:
177
194
  return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
178
195
  )
179
- return PackedStringArray(close.slice(0, 3))
196
+ # Every name that only adds to the word stays, since cutting at three named three corners of
197
+ # corner_radius and left out the fourth as if it were not there.
198
+ var named: PackedStringArray = PackedStringArray(close.slice(0, 3))
199
+ for name: String in close.slice(3):
200
+ if name.to_lower().begins_with(wanted):
201
+ var _added: bool = named.append(name)
202
+ return named
180
203
 
181
204
 
182
205
  ## [param said], the refusal for [param word], which is not among [param names], with those nearest
@@ -190,18 +213,70 @@ static func offered(said: String, word: String, names: Array) -> String:
190
213
 
191
214
 
192
215
  ## [param said], the refusal for [param property], which [param target] does not declare, with the
193
- ## names it declares nearest it when any are, as a misspelt one is usually one of them. Unlike
194
- ## [method offered], nothing is listed when none is near, since an object declares a hundred.
216
+ ## names it declares nearest it when any are, as a misspelt one is usually one of them, or else the
217
+ ## variables its script declares. The engine's own are not listed when none is near, since an object
218
+ ## has a hundred, while a script's are the few a caller most likely meant.
195
219
  static func near_property(said: String, target: Object, property: String) -> String:
196
220
  var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
197
221
  var names: Array[String] = []
222
+ var scripted: Array[String] = []
198
223
  for declared: Dictionary in target.get_property_list():
199
- if not as_int(declared.get("usage", 0)) & headings:
224
+ var usage: int = as_int(declared.get("usage", 0))
225
+ if usage & PROPERTY_USAGE_ARRAY:
226
+ var listed: String = _list_called(target, declared, property)
227
+ if not listed.is_empty():
228
+ return "%s; %s" % [said, listed]
229
+ if not usage & headings:
200
230
  names.append(str(declared.get("name", "")))
231
+ if usage & PROPERTY_USAGE_SCRIPT_VARIABLE and not usage & headings:
232
+ scripted.append(str(declared.get("name", "")))
201
233
  var close: PackedStringArray = nearest(property, PackedStringArray(names))
202
- if close.is_empty():
234
+ if not close.is_empty():
235
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
236
+ if scripted.is_empty():
203
237
  return said
204
- return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
238
+ scripted.sort()
239
+ return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
240
+
241
+
242
+ ## How [param target] keeps the list [param property] names, when that is the label of one of the
243
+ ## engine's numbered lists, such as an OptionButton's Items; empty when it is not. The engine has no
244
+ ## property by that name, only a count and each entry's fields under a numbered prefix, so "items"
245
+ ## was refused with nothing near it to offer.
246
+ static func _list_called(target: Object, count: Dictionary, property: String) -> String:
247
+ var parts: PackedStringArray = str(count.get("class_name", "")).split(",")
248
+ if parts.size() != 2 or _bare(parts[0]) != _bare(property):
249
+ return ""
250
+ var counted: String = str(count.get("name", ""))
251
+ var first: String = parts[1] + "0/"
252
+ var fields: PackedStringArray = _fields_at(target, first)
253
+ if fields.is_empty() and ClassDB.can_instantiate(target.get_class()):
254
+ var fresh: Object = ClassDB.instantiate(target.get_class())
255
+ fresh.set(counted, 1)
256
+ fields = _fields_at(fresh, first)
257
+ if not fresh is RefCounted:
258
+ fresh.free()
259
+ var each: String = "each one's fields" if fields.is_empty() else "each one's %s" % ", ".join(fields)
260
+ return (
261
+ (
262
+ "it keeps its %s as %s, how many there are, and %s under %s for the first, %s for the second and so on: "
263
+ + "give %s before the fields of the entries it makes"
264
+ )
265
+ % [parts[0].to_lower(), counted, each, first, parts[1] + "1/", counted]
266
+ )
267
+
268
+
269
+ static func _fields_at(target: Object, prefix: String) -> PackedStringArray:
270
+ var fields: PackedStringArray = PackedStringArray()
271
+ for declared: Dictionary in target.get_property_list():
272
+ var name: String = str(declared.get("name", ""))
273
+ if name.begins_with(prefix):
274
+ var _added: bool = fields.append(name.trim_prefix(prefix))
275
+ return fields
276
+
277
+
278
+ static func _bare(word: String) -> String:
279
+ return word.to_lower().replace("_", "").replace(" ", "")
205
280
 
206
281
 
207
282
  ## The signals [param object] has that a caller can connect, its own class's first: the engine
@@ -219,3 +294,34 @@ static func signals_of(object: Object) -> Array:
219
294
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
220
295
  static func with_article(word: String) -> String:
221
296
  return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
297
+
298
+
299
+ ## [param value], which the engine holds as a 32-bit float, as the shortest decimal that is still
300
+ ## that float. JSON carries a double, and the double a 32-bit 0.2 widens to is 0.200000002980232:
301
+ ## the same number, but not the one written or the one the file shows, so a caller comparing what it
302
+ ## set against what it read saw them differ. The shorter one, sent back, stores the same 32 bits. A
303
+ ## plain float is left alone by the serialiser, since nothing says whether the engine holds it in
304
+ ## 32 bits or 64.
305
+ static func single(value: float) -> float:
306
+ if not is_finite(value) or value == 0.0:
307
+ return value
308
+ var held: float = PackedFloat32Array([value])[0]
309
+ var magnitude: int = floori(log(absf(held)) / log(10.0))
310
+ for digits: int in range(1, 10):
311
+ var shift: int = digits - 1 - magnitude
312
+ var tried: float = (
313
+ roundf(held * pow(10.0, shift)) / pow(10.0, shift)
314
+ if shift >= 0
315
+ else roundf(held / pow(10.0, -shift)) * pow(10.0, -shift)
316
+ )
317
+ if PackedFloat32Array([tried])[0] == held:
318
+ return tried
319
+ return held
320
+
321
+
322
+ ## Whether a value of [param type] holds its floats in 32 bits: a colour always, and the vectors and
323
+ ## the shapes built of them unless the engine was built for 64.
324
+ static func holds_singles(type: int) -> bool:
325
+ if type in [TYPE_COLOR, TYPE_PACKED_COLOR_ARRAY, TYPE_PACKED_FLOAT32_ARRAY]:
326
+ return true
327
+ return not OS.has_feature("double") and type in SHAPES_OF_REALS
@@ -80,6 +80,12 @@ const SCENE_EXTENSIONS: PackedStringArray = ["tscn", "scn", "escn", "res", "tres
80
80
  ## `EDITOR_PID_VARIABLE` in the editor addon's bridge client.
81
81
  const EDITOR_PID_VARIABLE: String = "GDHARNESS_EDITOR_PID"
82
82
 
83
+ ## The directory the keeper holding this run reads, once the game has exited, to learn whether it
84
+ ## set a restart with OS.set_restart_on_exit. The engine starts the process it restarts into after
85
+ ## the tree has gone, and from outside that looks like a worker the game started in its last moments,
86
+ ## so only the game can say which it was. Kept in step with `EXIT_NOTES_VARIABLE` in src/keeper.ts.
87
+ const EXIT_NOTES_VARIABLE: String = "GDHARNESS_EXIT_NOTES"
88
+
83
89
  ## The file the engine's error reports go to, beside the announcement and named the same way,
84
90
  ## which is where the server reads them for a run the editor plays. Kept in step with
85
91
  ## `errorReportPath` in src/runtime-client.ts.
@@ -165,6 +171,7 @@ func _init() -> void:
165
171
  "wait_frames": _waits.wait_frames,
166
172
  "wait_signal": _waits.wait_signal,
167
173
  "watch_signal": _waits.watch_signal,
174
+ "watch_recorded": _waits.recorded,
168
175
  "wait_until": _waits.wait_until,
169
176
  "send_profile": _send_profile_now,
170
177
  "close_game": _close_game,
@@ -245,10 +252,25 @@ func _ready() -> void:
245
252
  var watched: int = get_tree().root.tree_exiting.connect(_send_the_profile)
246
253
  if watched != OK:
247
254
  push_error("gdharness could not watch for the game quitting: %d" % watched)
255
+ if not OS.get_environment(EXIT_NOTES_VARIABLE).is_empty():
256
+ var noted: int = get_tree().root.tree_exited.connect(_note_the_exit)
257
+ if noted != OK:
258
+ push_error("gdharness could not watch for the game's tree ending: %d" % noted)
248
259
  _report_errors()
249
260
  _start_server()
250
261
 
251
262
 
263
+ ## Says whether this game restarts as it exits, once the whole tree has gone, so a game's own
264
+ ## _exit_tree and every autoload's have had their say.
265
+ func _note_the_exit() -> void:
266
+ var path: String = OS.get_environment(EXIT_NOTES_VARIABLE).path_join("exit-%d.json" % OS.get_process_id())
267
+ var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
268
+ if file == null:
269
+ return
270
+ var _written: bool = file.store_string(JSON.stringify({"restart": OS.is_restart_on_exit_set()}))
271
+ file.close()
272
+
273
+
252
274
  func _exit_tree() -> void:
253
275
  if _errors != null:
254
276
  OS.remove_logger(_errors)
@@ -601,6 +623,9 @@ func _identity(bind_address: String) -> Dictionary:
601
623
  "path": ProjectSettings.globalize_path("res://").rstrip("/")
602
624
  },
603
625
  "godot": Engine.get_version_info().get("string", ""),
626
+ # Where user:// is for this run, which only the engine can say: it follows the project's
627
+ # name and settings and the environment the run was started in.
628
+ "user_dir": OS.get_user_data_dir(),
604
629
  "addon_version": _loaded_version,
605
630
  "addon_digest": _loaded_digest,
606
631
  }
@@ -670,6 +695,7 @@ func _execute_command(command: String, params: Dictionary) -> Dictionary:
670
695
  "type": "error",
671
696
  "message": "Unknown command: %s. Commands: %s" % [command, ", ".join(_commands.keys())]
672
697
  }
698
+ _waits.saw_call(command, params)
673
699
  var answer: Variant = await handler.call(params)
674
700
  return answer
675
701
 
@@ -723,13 +749,19 @@ func _send_error(client: StreamPeerTCP, request_id: Variant, message: String) ->
723
749
  ## auto_accept_quit off and decides for itself, and one whose player answered No went on running
724
750
  ## with no runtime and no announcement, so every later call said no game was running. A quit it
725
751
  ## does take ends the tree, which [method _exit_tree] cleans up after.
752
+ ##
753
+ ## One line for every notification, and the last this node runs in a frame, after [method _process]:
754
+ ## a pause that stops in this addon is let go to a breakpoint here and stepped once into the game's
755
+ ## own script, rather than stepped through the frame a line at a time.
726
756
  func _notification(what: int) -> void:
727
- if what != NOTIFICATION_WM_CLOSE_REQUEST:
728
- return
729
- if not is_inside_tree() or get_tree().auto_accept_quit:
757
+ if what == NOTIFICATION_WM_CLOSE_REQUEST and _takes_the_close():
730
758
  _cleanup()
731
759
 
732
760
 
761
+ func _takes_the_close() -> bool:
762
+ return not is_inside_tree() or get_tree().auto_accept_quit
763
+
764
+
733
765
  func _cleanup() -> void:
734
766
  for client: StreamPeerTCP in _clients:
735
767
  client.disconnect_from_host()
@@ -172,6 +172,11 @@ func _cut_asked(params: Dictionary, in_window: bool) -> Dictionary:
172
172
  return _refused(shape % JSON.stringify(region_given))
173
173
  asked["region"] = region
174
174
  if not node_path.is_empty():
175
+ # Before any refusal of the window: a headless game refused a path that named nothing for
176
+ # having no window, and the wrong path was found only after a restart with one.
177
+ var standing: Dictionary = GdhValues.node_at(_host.get_tree().root, node_path)
178
+ if standing.has("message"):
179
+ return standing
175
180
  asked["node_path"] = node_path
176
181
  return asked
177
182