gdharness 1.2.35 → 1.2.37

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 (28) hide show
  1. package/build/cli.js +478 -100
  2. package/build/godot/addons/gdharness_editor/headless_check.gd +374 -0
  3. package/build/godot/addons/gdharness_editor/headless_check.gd.uid +1 -0
  4. package/build/godot/addons/gdharness_editor/property_values.gd +3 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +345 -10
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +103 -5
  7. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -18
  8. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +24 -13
  9. package/build/godot/addons/gdharness_runtime/call_errors.gd +7 -2
  10. package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +9 -5
  11. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +33 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +21 -4
  13. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +7 -5
  14. package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +116 -3
  16. package/build/godot/addons/gdharness_runtime/serialisation.gd +103 -5
  17. package/build/godot/operations/classdb_queries.gd +22 -0
  18. package/build/godot/operations/dependencies.gd +90 -20
  19. package/build/godot/operations/editor_tools.gd +57 -0
  20. package/build/godot/operations/gdscript_analysis.gd +45 -2
  21. package/build/godot/operations/gdscript_authoring.gd +5 -2
  22. package/build/godot/operations/godot_operations.gd +53 -2
  23. package/build/godot/operations/property_values.gd +3 -0
  24. package/build/godot/operations/rename_checks.gd +0 -19
  25. package/build/godot/operations/script_check.gd +98 -0
  26. package/build/godot/operations/serialisation.gd +103 -5
  27. package/build/index.js +466 -90
  28. package/package.json +1 -1
@@ -4,6 +4,7 @@ extends Node
4
4
  ## Resource files, shaders, tilesets and themes, written through the open editor.
5
5
 
6
6
  const FileReads = preload("../file_reads.gd")
7
+ const HeadlessCheck = preload("../headless_check.gd")
7
8
  const PropertyValues = preload("../property_values.gd")
8
9
  const Read = preload("../reading.gd")
9
10
  const SceneFile = preload("../scene_file.gd")
@@ -118,9 +119,12 @@ func _load_theme(theme_path: String) -> Theme:
118
119
  ## Read back because a property can be set and still not be saved: one without storage, a
119
120
  ## script variable that is not exported. Both were answered as changed.
120
121
  func _save_checked(resource: Resource, path: String, names: Array, previous: Variant) -> String:
121
- var saving: Error = ResourceSaver.save(resource, path)
122
+ var saving: Error = SceneFile.save_keeping_uid(resource, path)
122
123
  if saving != OK:
123
124
  return "Failed to save %s: %s" % [path, error_string(saving)]
125
+ var unkept: String = _unkept_headless(resource, path, names, previous)
126
+ if not unkept.is_empty():
127
+ return "Nothing was written: " + unkept + HeadlessCheck.put_back(path, previous)
124
128
  var loaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
125
129
  var lost: String = ""
126
130
  if loaded == null:
@@ -141,14 +145,50 @@ func _save_checked(resource: Resource, path: String, names: Array, previous: Var
141
145
  if lost.is_empty():
142
146
  _written(path)
143
147
  return ""
148
+ return lost + HeadlessCheck.put_back(path, previous)
149
+
150
+
151
+ ## Outside the editor, puts back in the file just saved at [param path] what a script that is not a
152
+ ## tool changed and the call did not ask for, [param names] being what it asked for, and answers ""
153
+ ## or why that did not hold. See [HeadlessCheck].
154
+ func _unkept_headless(resource: Resource, path: String, names: Array, previous: Variant) -> String:
155
+ if Engine.is_editor_hint():
156
+ return ""
157
+ var script: Script = resource.get_script()
158
+ var scripted: bool = script != null and not script.is_tool()
159
+ if not path.ends_with(".tres"):
160
+ if scripted or HeadlessCheck.holds_scripted_embedded(resource, {}):
161
+ return (
162
+ (
163
+ (
164
+ "%s is a binary resource with a script that is not a tool, and what that script "
165
+ + "changed when a headless engine loaded it cannot be told apart from the file here. "
166
+ )
167
+ % path
168
+ )
169
+ + HeadlessCheck.REMEDY
170
+ )
171
+ return ""
172
+ var before: String = ""
144
173
  if previous is PackedByteArray:
145
174
  var bytes: PackedByteArray = previous
146
- var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
147
- if file == null or not file.store_buffer(bytes):
148
- return lost + " Putting the file back as it was failed too."
149
- elif DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) != OK:
150
- return lost + " Removing the file it wrote failed too."
151
- return lost
175
+ before = bytes.get_string_from_utf8()
176
+ var asked: Dictionary = {"script": true}
177
+ var reached: Dictionary = {}
178
+ for key: Variant in names:
179
+ var property: String = str(key)
180
+ asked[property] = true
181
+ asked[property.get_slice(":", 0)] = true
182
+ reached.merge(HeadlessCheck.reached(resource, property))
183
+ return HeadlessCheck.settle_saved(
184
+ path,
185
+ before,
186
+ HeadlessCheck.stored(resource).values(),
187
+ resource if scripted else null,
188
+ asked,
189
+ reached,
190
+ func() -> Error: return SceneFile.save_keeping_uid(resource, path)
191
+ )
152
192
 
153
193
 
154
194
  func create_resource(args: Dictionary) -> Dictionary:
@@ -321,7 +361,7 @@ func create_tileset(args: Dictionary) -> Dictionary:
321
361
  var folder: String = SceneFile.make_folder_for(tileset_path)
322
362
  if not folder.is_empty():
323
363
  return {"ok": false, "error": folder}
324
- var saving: Error = ResourceSaver.save(tileset, tileset_path)
364
+ var saving: Error = SceneFile.save_keeping_uid(tileset, tileset_path)
325
365
  if saving != OK:
326
366
  return {"ok": false, "error": "Failed to save %s: %s" % [tileset_path, error_string(saving)]}
327
367
 
@@ -443,6 +483,7 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
443
483
  # reports placing tiles and a scene that shows none.
444
484
  var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
445
485
 
486
+ scene.asked(node, ["layer_%d/tile_data" % layer if layered != null else "tile_map_data"])
446
487
  var wanted: Array = []
447
488
  for index: int in placed.size():
448
489
  var cell: Array = _cell(placed[index], index, tile_set)
@@ -621,6 +662,19 @@ func _cell_at(node: Node, layer: int, at: Vector2i) -> Array:
621
662
  return []
622
663
 
623
664
 
665
+ ## Saves [param theme] over [param theme_path] with [param item] changed, answering "" or why not.
666
+ func _save_theme(theme: Theme, theme_path: String, item: String) -> String:
667
+ var previous: PackedByteArray = FileAccess.get_file_as_bytes(theme_path)
668
+ var saving: Error = SceneFile.save_keeping_uid(theme, theme_path)
669
+ if saving != OK:
670
+ return "Failed to save %s: %s" % [theme_path, error_string(saving)]
671
+ var unkept: String = _unkept_headless(theme, theme_path, [item], previous)
672
+ if not unkept.is_empty():
673
+ return "Nothing was written: " + unkept + HeadlessCheck.put_back(theme_path, previous)
674
+ _written(theme_path)
675
+ return ""
676
+
677
+
624
678
  func set_theme_color(args: Dictionary) -> Dictionary:
625
679
  var theme_path: String = _ensure_res_path(str(args.get("themePath", "")))
626
680
  if theme_path == "res://":
@@ -644,11 +698,9 @@ func set_theme_color(args: Dictionary) -> Dictionary:
644
698
  )
645
699
  theme.set_color(color_name, control_type, color)
646
700
 
647
- var save_result: Error = ResourceSaver.save(theme, theme_path)
648
- if save_result != OK:
649
- return {"ok": false, "error": "Failed to save %s: %s" % [theme_path, error_string(save_result)]}
650
-
651
- _written(theme_path)
701
+ var unsaved: String = _save_theme(theme, theme_path, "%s/colors/%s" % [control_type, color_name])
702
+ if not unsaved.is_empty():
703
+ return {"ok": false, "error": unsaved}
652
704
 
653
705
  var saved: Theme = _load_theme(theme_path)
654
706
  if saved == null or not saved.has_color(color_name, control_type):
@@ -680,11 +732,9 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
680
732
 
681
733
  theme.set_font_size(font_size_name, control_type, Read.as_int(args.get("size", 0)))
682
734
 
683
- var save_result: Error = ResourceSaver.save(theme, theme_path)
684
- if save_result != OK:
685
- return {"ok": false, "error": "Failed to save %s: %s" % [theme_path, error_string(save_result)]}
686
-
687
- _written(theme_path)
735
+ var unsaved: String = _save_theme(theme, theme_path, "%s/font_sizes/%s" % [control_type, font_size_name])
736
+ if not unsaved.is_empty():
737
+ return {"ok": false, "error": unsaved}
688
738
 
689
739
  var saved: Theme = _load_theme(theme_path)
690
740
  if saved == null or not saved.has_font_size(font_size_name, control_type):
@@ -90,7 +90,7 @@ func _missing_on(node: Node, missing: String) -> String:
90
90
  if probe.reload() == OK:
91
91
  return missing
92
92
  return (
93
- "%s. Its script, %s, does not compile, so the editor knows none of what it declares."
93
+ "%s. Its script, %s, does not compile, so none of what it declares is known here."
94
94
  % [missing, script.resource_path]
95
95
  )
96
96
 
@@ -213,6 +213,10 @@ func create_scene(args: Dictionary) -> Dictionary:
213
213
 
214
214
  var root: Node = ClassDB.instantiate(root_node_type)
215
215
  root.name = root_node_name
216
+ var scene: SceneFile = SceneFile.new()
217
+ scene.root = root
218
+ scene.path = scene_path
219
+ scene.asked(root, ["script"])
216
220
 
217
221
  if not script_path.is_empty():
218
222
  var full_script_path: String = _to_scene_res_path(project_path, script_path)
@@ -238,9 +242,6 @@ func create_scene(args: Dictionary) -> Dictionary:
238
242
  if not folder.is_empty():
239
243
  root.free()
240
244
  return {"ok": false, "error": folder}
241
- var scene: SceneFile = SceneFile.new()
242
- scene.root = root
243
- scene.path = scene_path
244
245
  var written: Dictionary = scene.write()
245
246
  if not written.is_empty():
246
247
  return written
@@ -307,6 +308,10 @@ func add_node(args: Dictionary) -> Dictionary:
307
308
  parent.add_child(new_node)
308
309
  Placing.place(new_node, place)
309
310
  Placing.own(new_node, root)
311
+ var unsettled: String = scene.settle()
312
+ if not unsettled.is_empty():
313
+ return scene.refuse("Nothing was written: " + unsettled)
314
+ scene.asked(new_node, properties.keys())
310
315
  var refused_property: String = _properties.write_all(new_node, properties)
311
316
  if not refused_property.is_empty():
312
317
  return scene.refuse(refused_property)
@@ -415,6 +420,7 @@ func duplicate_node(args: Dictionary) -> Dictionary:
415
420
  target_parent.add_child(duplicated_node)
416
421
  Placing.own(duplicated_node, root)
417
422
  Placing.reconnect(copies, source)
423
+ scene.copied(copies)
418
424
 
419
425
  var new_path: String = scene.path_of(duplicated_node)
420
426
  var written: Dictionary = scene.write(
@@ -530,6 +536,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
530
536
  if not unusable.is_empty():
531
537
  return scene.refuse(unusable)
532
538
 
539
+ scene.asked(node, properties.keys())
533
540
  var refused_property: String = _properties.write_all(node, properties)
534
541
  if not refused_property.is_empty():
535
542
  return scene.refuse(_missing_on(node, refused_property))
@@ -570,12 +577,10 @@ func get_node_properties(args: Dictionary) -> Dictionary:
570
577
  var props: Dictionary = _properties_of(node, include_defaults, scene.set_here(node))
571
578
  var cells: Dictionary = ResourceTools.cells_of(node)
572
579
  scene.close()
573
- var answer: Dictionary = {
574
- "ok": true,
575
- "nodePath": node_path,
576
- "properties": props,
577
- "unsavedInEditor": SceneFile.unsaved(scene_path)
578
- }
580
+ var answer: Dictionary = {"ok": true, "nodePath": node_path, "properties": props}
581
+ # Only an editor holds anything unsaved; a headless engine answering false would say it looked.
582
+ if Engine.is_editor_hint():
583
+ answer["unsavedInEditor"] = SceneFile.unsaved(scene_path)
579
584
  answer.merge(cells)
580
585
  # An empty answer read as a node with nothing on it, rather than one with nothing changed.
581
586
  if props.is_empty() and not include_defaults:
@@ -600,7 +605,10 @@ func save_scene(args: Dictionary) -> Dictionary:
600
605
  return {"ok": false, "error": unsaved}
601
606
 
602
607
  var open_root: Node = null
603
- for open: Node in EditorInterface.get_open_scene_roots():
608
+ var open_roots: Array[Node] = []
609
+ if Engine.is_editor_hint():
610
+ open_roots = EditorInterface.get_open_scene_roots()
611
+ for open: Node in open_roots:
604
612
  if open.scene_file_path == scene_path:
605
613
  open_root = open
606
614
  if open_root != null and target_path == scene_path:
@@ -618,7 +626,7 @@ func save_scene(args: Dictionary) -> Dictionary:
618
626
  written = SceneFile.save_packed(packed, target_path)
619
627
  else:
620
628
  var scene: SceneFile = SceneFile.new()
621
- var refused: Dictionary = scene.open(scene_path, false)
629
+ var refused: Dictionary = scene.open(scene_path, true)
622
630
  if not refused.is_empty():
623
631
  return refused
624
632
  scene.path = target_path
@@ -897,4 +905,7 @@ func list_connections(args: Dictionary) -> Dictionary:
897
905
  )
898
906
 
899
907
  scene.close()
900
- return {"ok": true, "connections": connections, "unsavedInEditor": SceneFile.unsaved(scene_path)}
908
+ var listed: Dictionary = {"ok": true, "connections": connections}
909
+ if Engine.is_editor_hint():
910
+ listed["unsavedInEditor"] = SceneFile.unsaved(scene_path)
911
+ return listed
@@ -12,8 +12,10 @@ const REFUSED: String = "Error calling method from 'callv': "
12
12
 
13
13
  ## The engine's reason for refusing the call, without [constant REFUSED], or "" when it ran.
14
14
  var refused: String = ""
15
- ## What was reported while it ran, a line each, warnings marked as such.
15
+ ## The errors reported while it ran, a line each.
16
16
  var raised: Array[String] = []
17
+ ## The warnings reported while it ran, a line each.
18
+ var warned: Array[String] = []
17
19
 
18
20
 
19
21
  func _log_message(_message: String, _error: bool) -> void:
@@ -39,4 +41,7 @@ func _log_error(
39
41
  if function == "callv" and text.begins_with(REFUSED) and refused.is_empty():
40
42
  refused = text.trim_prefix(REFUSED)
41
43
  return
42
- raised.append(("WARNING: " if error_type == ERROR_TYPE_WARNING else "") + text)
44
+ if error_type == ERROR_TYPE_WARNING:
45
+ warned.append(text)
46
+ else:
47
+ raised.append(text)
@@ -108,6 +108,9 @@ try {
108
108
  }
109
109
  $p = Patterned $element 'RangeValue'
110
110
  if ($p) {
111
+ # A reader speaks the Value pattern's words, such as "75%" for a slider whose game sets them,
112
+ # and the number would otherwise replace them.
113
+ if ($node.Contains('value') -and "$($node.value)") { $node.value_text = $node.value }
111
114
  $node.value = $p.Current.Value
112
115
  $node.range = @($p.Current.Minimum, $p.Current.Maximum)
113
116
  if (-not $p.Current.IsReadOnly) { $actions += 'set_value' }
@@ -389,17 +392,18 @@ static func _narrow(result: Dictionary, params: Dictionary) -> void:
389
392
 
390
393
 
391
394
  ## Whether [param element] is made by the node at [param under] or one below it, and says
392
- ## [param said], each where asked: words as find matches them, in the element's name or value.
395
+ ## [param said], each where asked: words as find matches them, in the element's name, value or
396
+ ## value_text.
393
397
  static func _matches(element: Dictionary, under: String, said: String) -> bool:
394
398
  if not under.is_empty():
395
399
  var path: String = str(element.get("node", element.get("part_of", "")))
396
400
  if path != under and not path.begins_with(under + "/"):
397
401
  return false
398
402
  if not said.is_empty():
399
- var name: String = str(element.get("name", ""))
400
- var value: String = str(element.get("value", ""))
401
- if not Says.matches(name, said) and not Says.matches(value, said):
402
- return false
403
+ for field: String in ["name", "value", "value_text"]:
404
+ if element.has(field) and Says.matches(str(element[field]), said):
405
+ return true
406
+ return false
403
407
  return true
404
408
 
405
409
 
@@ -18,6 +18,7 @@ const InputCommands = preload("runtime_input.gd")
18
18
  const Pad = preload("runtime_pad.gd")
19
19
  const Queries = preload("runtime_queries.gd")
20
20
  const Read = preload("reading.gd")
21
+ const Targets = preload("runtime_targets.gd")
21
22
  const Typing = preload("runtime_typing.gd")
22
23
  const Values = preload("runtime_values.gd")
23
24
  const Waits = preload("runtime_waits.gd")
@@ -153,6 +154,7 @@ func _init() -> void:
153
154
  "choose": _typing.choose,
154
155
  "wait_frames": _waits.wait_frames,
155
156
  "wait_signal": _waits.wait_signal,
157
+ "watch_signal": _waits.watch_signal,
156
158
  "wait_until": _waits.wait_until,
157
159
  "send_profile": _send_profile_now,
158
160
  "close_game": _close_game,
@@ -160,6 +162,36 @@ func _init() -> void:
160
162
  _wait_for_the_profiler()
161
163
 
162
164
 
165
+ ## Gives a game with no window the size the project opens its window at. Measured: a
166
+ ## headless engine's root viewport is 64 by 64 whatever the project settings or --resolution say, so
167
+ ## a headless game laid its interface out in 64 pixels, and a button every windowed run shows was
168
+ ## refused as off screen. The root takes a size set once the autoloads are in the tree, before the
169
+ ## main scene enters it, and the scene lays out at it from its first frame. A size set at the
170
+ ## autoload's construction does not hold: the root is a 100 by 100 placeholder then, and the engine
171
+ ## gives it the 64 by 64 afterwards. A script run is not a game and keeps the viewport its own code
172
+ ## gives it, and a release build is left as the engine made it.
173
+ func _size_a_windowless_game() -> void:
174
+ if DisplayServer.get_name() != "headless" or _script_run() or not OS.is_debug_build():
175
+ return
176
+ var root: Window = get_tree().root
177
+ if Vector2(root.size) != Targets.HEADLESS_VIEWPORT:
178
+ return
179
+ root.size = Vector2i(_window_dimension("width", 1152), _window_dimension("height", 648))
180
+
181
+
182
+ ## The window's [param axis] as the project opens it: the override when one is set, otherwise the
183
+ ## viewport's size, whose engine default is [param fallback].
184
+ static func _window_dimension(axis: String, fallback: int) -> int:
185
+ var override: int = Read.as_int(
186
+ ProjectSettings.get_setting("display/window/size/window_%s_override" % axis, 0)
187
+ )
188
+ if override > 0:
189
+ return override
190
+ return Read.as_int(
191
+ ProjectSettings.get_setting("display/window/size/viewport_%s" % axis, fallback), fallback
192
+ )
193
+
194
+
163
195
  ## Holds the game's start until the profiler is on, when whatever started the game asked for a
164
196
  ## profile from its start. The debugger switches the profiler on with a message the game reads only
165
197
  ## on a poll, which comes every [constant POLLS_PER_READ] lines of script, so the main scene's _ready
@@ -197,6 +229,7 @@ func _ready() -> void:
197
229
  # even be un-paused over the socket. A debug server has to stay responsive while the game
198
230
  # is frozen, to inspect, capture, inject or resume it.
199
231
  process_mode = Node.PROCESS_MODE_ALWAYS
232
+ _size_a_windowless_game()
200
233
  var watched: int = get_tree().root.tree_exiting.connect(_send_the_profile)
201
234
  if watched != OK:
202
235
  push_error("gdharness could not watch for the game quitting: %d" % watched)
@@ -168,6 +168,12 @@ func _argument_for(
168
168
  % [called, named, type_string(wants), index + 1, JSON.stringify(sent)]
169
169
  )
170
170
  }
171
+ var rounded: String = Values.inexact(sent, wants)
172
+ if not rounded.is_empty():
173
+ return {
174
+ "type": "error",
175
+ "message": "%s.%s takes int as argument %d: %s." % [called, named, index + 1, rounded]
176
+ }
171
177
  var given: Variant = _values.fitted(sent, wants)
172
178
  if not Values.acceptable(given, wants):
173
179
  return {
@@ -242,6 +248,9 @@ func _value_for(
242
248
  "message": "%s.%s holds an object: %s" % [called, named, named_object["message"]]
243
249
  }
244
250
  return {"value": named_object["object"]}
251
+ var rounded: String = Values.inexact(value, wanted)
252
+ if not rounded.is_empty():
253
+ return {"type": "error", "message": "%s.%s holds int: %s." % [called, named, rounded]}
245
254
  var as_sent: Variant = _values.fitted(value, TYPE_NIL)
246
255
  var cut: bool = Values.cuts(value, wanted)
247
256
  var given: Variant = as_sent
@@ -441,16 +450,24 @@ func call_method(params: Dictionary) -> Dictionary:
441
450
  "result": _values.serialize(result),
442
451
  "elapsed_usec": elapsed
443
452
  }
444
- if not caught.raised.is_empty():
445
- answer["errors"] = caught.raised.slice(0, ERRORS_KEPT)
446
- if caught.raised.size() > ERRORS_KEPT:
447
- answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
453
+ _keep_reported(answer, "errors", caught.raised)
454
+ _keep_reported(answer, "warnings", caught.warned)
448
455
  var asked: Variant = params.get("properties")
449
456
  if _reads_the_result(asked):
450
457
  answer.merge(_read_off_result(result, asked))
451
458
  return answer
452
459
 
453
460
 
461
+ ## Puts the first [constant ERRORS_KEPT] of [param reported] in [param answer] under [param field],
462
+ ## with how many more there were, when anything was reported.
463
+ static func _keep_reported(answer: Dictionary, field: String, reported: Array[String]) -> void:
464
+ if reported.is_empty():
465
+ return
466
+ answer[field] = reported.slice(0, ERRORS_KEPT)
467
+ if reported.size() > ERRORS_KEPT:
468
+ answer[field + "_not_shown"] = reported.size() - ERRORS_KEPT
469
+
470
+
454
471
  ## Why [param named], the last step of a call's method, names no method of [param holder] to call, or
455
472
  ## "" when it does. Brackets on it holding arguments are refused rather than read: the arguments are
456
473
  ## taken from args alone, so a call is never made with half of them in one place and half in the
@@ -412,17 +412,19 @@ static func _outside_note(outside: int, root: Node) -> String:
412
412
  ## headless game's button twenty pixels in from the corner was refused as off screen, and the
413
413
  ## refusal read as though the button were not there.
414
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.
415
+ ## The size named is the one in force, which the rect printed beside it agrees with. A game the
416
+ ## runtime started headless has the size the project opens its window at, and a window of that size
417
+ ## would not reach the control either, so only the engine's own 64 by 64 is answered with a window.
417
418
  static func no_window_note(viewport: Viewport, root: Window) -> String:
418
419
  if viewport != root or root.can_draw():
419
420
  return ""
420
- if viewport.get_visible_rect().size == HEADLESS_VIEWPORT:
421
+ var size: Vector2 = viewport.get_visible_rect().size
422
+ if size == HEADLESS_VIEWPORT:
421
423
  return (
422
424
  ". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
423
425
  + "the project settings say: run it with a window to reach this control"
424
426
  )
425
- return ". This game has no window: run it with a window to reach this control"
427
+ return ". This game has no window, and its viewport is %d by %d" % [roundi(size.x), roundi(size.y)]
426
428
 
427
429
 
428
430
  ## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
@@ -771,7 +773,7 @@ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[Scrol
771
773
  ## Whether a click could not reach [param target] however it scrolled: its centre, where the click
772
774
  ## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
773
775
  ## container the click does not scroll. A drawer parked off the side of the screen, or anything
774
- ## outside the 64 by 64 viewport of a game with no window, was listed among the controls on screen.
776
+ ## outside the viewport of a game with no window, was listed among the controls on screen.
775
777
  static func _off_screen(target: Control) -> bool:
776
778
  var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
777
779
  var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
@@ -156,6 +156,11 @@ static func cuts(given: Variant, type: int) -> bool:
156
156
  return not (whole and (value == 0.0 or value == 1.0))
157
157
 
158
158
 
159
+ ## Why [param given] may not be the whole number sent, or "": see [method Serialisation.inexact].
160
+ static func inexact(given: Variant, type: int) -> String:
161
+ return Serialisation.inexact(given, type)
162
+
163
+
159
164
  ## How a value of [param type] is written to be read as one, or empty when it is not built from fields.
160
165
  static func written_as(type: int) -> String:
161
166
  return Serialisation.written_as(type)
@@ -26,8 +26,13 @@ const LOOKS_APART: int = 9
26
26
  ## two looks and a wait for 32 never sees it.
27
27
  const COMPARES: Array[String] = ["==", "!=", ">=", "<=", ">", "<"]
28
28
 
29
+ ## The most signal watches kept at once; arming one past this lets the oldest go.
30
+ const MOST_WATCHES: int = 16
31
+
29
32
  var _host: Node
30
33
  var _values: Values
34
+ var _watches: Dictionary[int, Watch] = {}
35
+ var _next_watch: int = 1
31
36
 
32
37
 
33
38
  func _init(host: Node, values: Values) -> void:
@@ -56,13 +61,16 @@ func wait_frames(params: Dictionary) -> Dictionary:
56
61
  return {"type": "waited", "frames": frames, "elapsed_ms": Time.get_ticks_msec() - started}
57
62
 
58
63
 
59
- ## Waits for a signal to fire, and answers with what it carried, or that the time ran out.
64
+ ## Waits for a signal to fire, and answers with what it carried, or that the time ran out. Given a
65
+ ## watch, collects that instead: see [method watch_signal].
60
66
  func wait_signal(params: Dictionary) -> Dictionary:
61
67
  var node_path: String = str(params.get("path", ""))
62
68
  var signal_name: String = str(params.get("signal", ""))
63
69
  var timeout_ms: int = Read.as_int(params.get("timeout_ms", 5000), 5000)
64
70
  if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
65
71
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
72
+ if params.has("watch"):
73
+ return await _collect(Read.as_int(params["watch"], 0), timeout_ms)
66
74
  if node_path.is_empty() or signal_name.is_empty():
67
75
  return {"type": "error", "message": "Node path and signal name required"}
68
76
 
@@ -84,7 +92,7 @@ func wait_signal(params: Dictionary) -> Dictionary:
84
92
  if not catcher.fired and is_instance_valid(node) and node.is_connected(signal_name, callable):
85
93
  node.disconnect(signal_name, callable)
86
94
 
87
- return {
95
+ var answer: Dictionary = {
88
96
  "type": "signal",
89
97
  "path": node_path,
90
98
  "signal": signal_name,
@@ -92,6 +100,90 @@ func wait_signal(params: Dictionary) -> Dictionary:
92
100
  "args": _values.serialize(catcher.args),
93
101
  "elapsed_ms": Time.get_ticks_msec() - started,
94
102
  }
103
+ if not catcher.fired:
104
+ answer["note"] = (
105
+ "If a call meant to set the signal off was sent beside this wait, it may have run only after"
106
+ + " the wait answered, as a client can run its calls in turn: arm runtime_wait watch first,"
107
+ + " make the call, then wait with signal and that watch"
108
+ )
109
+ return answer
110
+
111
+
112
+ ## Starts recording a signal and answers at once with the watch to collect it by.
113
+ ##
114
+ ## A wait sent beside the call meant to set its signal off does not see it when the client runs the
115
+ ## two in turn: Claude Code ran a key press only after the signal wait beside it had answered, so the
116
+ ## wait ran out and the press landed after it. A watch is armed before the call and collected after.
117
+ func watch_signal(params: Dictionary) -> Dictionary:
118
+ var node_path: String = str(params.get("path", ""))
119
+ var signal_name: String = str(params.get("signal", ""))
120
+ if node_path.is_empty() or signal_name.is_empty():
121
+ return {"type": "error", "message": "Node path and signal name required"}
122
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
123
+ if standing.has("message"):
124
+ return standing
125
+ var node: Node = standing["node"]
126
+ if not node.has_signal(signal_name):
127
+ return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
128
+ var watch: Watch = Watch.new(node, node_path, signal_name)
129
+ var listening: Error = node.connect(signal_name, watch.catcher._on_fired)
130
+ if listening != OK:
131
+ return {"type": "error", "message": "Could not listen to signal: " + signal_name}
132
+ while _watches.size() >= MOST_WATCHES:
133
+ var oldest: int = _watches.keys().min()
134
+ _let_go(oldest)
135
+ var id: int = _next_watch
136
+ _next_watch += 1
137
+ _watches[id] = watch
138
+ return {"type": "watching", "watch": id, "path": node_path, "signal": signal_name}
139
+
140
+
141
+ ## Answers at once with the first emission recorded since watch [param id] was armed, otherwise
142
+ ## waits for the next until the time runs out. The watch goes either way.
143
+ func _collect(id: int, timeout_ms: int) -> Dictionary:
144
+ if not _watches.has(id):
145
+ return {
146
+ "type": "error",
147
+ "message":
148
+ (
149
+ (
150
+ "No watch %d is armed in this game: it was collected already, %d armed after it let it"
151
+ + " go, or the game started again since"
152
+ )
153
+ % [id, MOST_WATCHES]
154
+ ),
155
+ }
156
+ var watch: Watch = _watches[id]
157
+ var started: int = Time.get_ticks_msec()
158
+ var before: bool = watch.catcher.fired
159
+ while not watch.catcher.fired and Time.get_ticks_msec() - started < timeout_ms:
160
+ await _host.get_tree().process_frame
161
+ _let_go(id)
162
+ var answer: Dictionary = {
163
+ "type": "signal",
164
+ "path": watch.node_path,
165
+ "signal": watch.signal_name,
166
+ "watch": id,
167
+ "fired": watch.catcher.fired,
168
+ "times": watch.catcher.times,
169
+ "args": _values.serialize(watch.catcher.args),
170
+ "elapsed_ms": Time.get_ticks_msec() - started,
171
+ }
172
+ if before:
173
+ answer["fired_before_wait"] = true
174
+ return answer
175
+
176
+
177
+ func _let_go(id: int) -> void:
178
+ var watch: Watch = _watches.get(id)
179
+ var _gone: bool = _watches.erase(id)
180
+ if watch == null:
181
+ return
182
+ var held: Object = watch.node.get_ref()
183
+ var node: Node = held as Node
184
+ var callable: Callable = watch.catcher._on_fired
185
+ if node != null and node.is_connected(watch.signal_name, callable):
186
+ node.disconnect(watch.signal_name, callable)
95
187
 
96
188
 
97
189
  ## Waits until a property reads as the value given, or until something under the path says what
@@ -163,6 +255,9 @@ func wait_until(params: Dictionary) -> Dictionary:
163
255
  var refused: String = _not_comparable(current, wanted, node_path, property)
164
256
  if refused.is_empty():
165
257
  refused = _cut_to_fit(params["value"], typeof(current), node_path, property)
258
+ var rounded: String = Values.inexact(params["value"], typeof(current))
259
+ if refused.is_empty() and not rounded.is_empty():
260
+ refused = "%s.%s holds int: %s." % [node_path, property, rounded]
166
261
  var never_held: bool = typeof(wanted) != TYPE_NIL and typeof(wanted) != TYPE_OBJECT
167
262
  if refused.is_empty() and declared == TYPE_OBJECT and never_held:
168
263
  refused = (
@@ -492,8 +587,26 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
492
587
  class SignalCatcher:
493
588
  extends RefCounted
494
589
  var fired: bool = false
590
+ ## How many times it fired; [member args] holds what the first carried.
591
+ var times: int = 0
495
592
  var args: Array = []
496
593
 
497
594
  func _on_fired(...carried: Array) -> void:
595
+ if not fired:
596
+ args = carried
498
597
  fired = true
499
- args = carried
598
+ times += 1
599
+
600
+
601
+ ## A signal recorded from when it was armed until it is collected.
602
+ class Watch:
603
+ extends RefCounted
604
+ var node: WeakRef
605
+ var node_path: String
606
+ var signal_name: String
607
+ var catcher: SignalCatcher = SignalCatcher.new()
608
+
609
+ func _init(watched: Node, path: String, named: String) -> void:
610
+ node = weakref(watched)
611
+ node_path = path
612
+ signal_name = named