gdharness 1.3.9 → 1.3.11

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 (73) hide show
  1. package/build/cli.js +4740 -3212
  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 +102 -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 +51 -1
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +77 -140
  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 +11 -22
  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 +28 -5
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +39 -20
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -362
  18. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  21. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +147 -492
  24. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  25. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -3
  26. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  27. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +79 -5
  28. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  29. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +183 -37
  30. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  31. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  32. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  33. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  34. package/build/godot/addons/gdharness_runtime/reading.gd +51 -1
  35. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  36. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +185 -30
  37. package/build/godot/addons/gdharness_runtime/runtime_input.gd +86 -79
  38. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  39. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  40. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +309 -22
  41. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +163 -20
  42. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  43. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +5 -2
  44. package/build/godot/addons/gdharness_runtime/runtime_values.gd +146 -5
  45. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  46. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  47. package/build/godot/addons/gdharness_runtime/serialisation.gd +11 -22
  48. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  49. package/build/godot/operations/animation_reads.gd +115 -4
  50. package/build/godot/operations/audio_buses.gd +93 -7
  51. package/build/godot/operations/classdb_queries.gd +119 -7
  52. package/build/godot/operations/declared_values.gd +272 -0
  53. package/build/godot/operations/dependencies.gd +154 -41
  54. package/build/godot/operations/editor_tools.gd +22 -14
  55. package/build/godot/operations/export_presets.gd +602 -4
  56. package/build/godot/operations/file_reads.gd +102 -18
  57. package/build/godot/operations/gdscript_authoring.gd +11 -1
  58. package/build/godot/operations/godot_operations.gd +16 -2
  59. package/build/godot/operations/import_pipeline.gd +20 -221
  60. package/build/godot/operations/input_actions.gd +108 -47
  61. package/build/godot/operations/project_config.gd +83 -69
  62. package/build/godot/operations/project_diagnostics.gd +4 -0
  63. package/build/godot/operations/project_validation.gd +266 -0
  64. package/build/godot/operations/property_paths.gd +89 -16
  65. package/build/godot/operations/property_values.gd +180 -121
  66. package/build/godot/operations/reading.gd +51 -1
  67. package/build/godot/operations/scene_connections.gd +222 -0
  68. package/build/godot/operations/serialisation.gd +11 -22
  69. package/build/godot/operations/setting_reads.gd +121 -0
  70. package/build/godot/operations/value_shapes.gd +128 -1
  71. package/build/godot/tests/listed.gd +73 -13
  72. package/build/keeper.js +1371 -706
  73. 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) + "."
@@ -193,7 +193,13 @@ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray
193
193
  func(a: String, b: String) -> bool:
194
194
  return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
195
195
  )
196
- 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
197
203
 
198
204
 
199
205
  ## [param said], the refusal for [param word], which is not among [param names], with those nearest
@@ -216,6 +222,10 @@ static func near_property(said: String, target: Object, property: String) -> Str
216
222
  var scripted: Array[String] = []
217
223
  for declared: Dictionary in target.get_property_list():
218
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]
219
229
  if not usage & headings:
220
230
  names.append(str(declared.get("name", "")))
221
231
  if usage & PROPERTY_USAGE_SCRIPT_VARIABLE and not usage & headings:
@@ -229,6 +239,46 @@ static func near_property(said: String, target: Object, property: String) -> Str
229
239
  return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
230
240
 
231
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(" ", "")
280
+
281
+
232
282
  ## The signals [param object] has that a caller can connect, its own class's first: the engine
233
283
  ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
234
284
  ## starting with an underscore are the engine's own.
@@ -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()