gdharness 1.1.22 → 1.1.23

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.
@@ -98,12 +98,21 @@ var _tried_announced: bool = false
98
98
  var _told_about_url: String = ""
99
99
  var _connecting_for: float = 0.0
100
100
 
101
+ ## Whether this editor was started by another editor, which is Godot's own restart and the project
102
+ ## manager opening a project: the new editor is the old one's child and has its environment, with
103
+ ## the variable this addon sets as it loads still naming the old one. Its arguments are gone, since
104
+ ## the engine consumes `--lsp-port`, `--dap-port` and `--log-file` and hands none of them back, so the
105
+ ## ports and the marker a server put in that environment describe the editor before this one.
106
+ var _started_by_an_editor: bool = false
107
+
101
108
 
102
109
  func _ready() -> void:
103
110
  _project_path = ProjectSettings.globalize_path("res://")
104
111
  version_at_load = _loaded_version()
105
112
  digest_at_load = _loaded_digest()
106
113
  _keep_breakpoints_through_sessions()
114
+ var inherited: String = OS.get_environment(EDITOR_PID_VARIABLE)
115
+ _started_by_an_editor = not inherited.is_empty() and inherited != str(OS.get_process_id())
107
116
  OS.set_environment(EDITOR_PID_VARIABLE, str(OS.get_process_id()))
108
117
 
109
118
  _reconnect_timer = Timer.new()
@@ -306,7 +315,8 @@ func _handle_connect() -> void:
306
315
  # Whether a server started this editor, which is the only kind that can be started again
307
316
  # as itself: the engine hands back none of the arguments it was given, so the arguments
308
317
  # have to come from whoever wrote them. See `restart_editor` in play_tools.gd.
309
- "opened_by_a_server": _opened_by_a_server()
318
+ "opened_by_a_server": _opened_by_a_server() and not _started_by_an_editor,
319
+ "started_by_an_editor": _started_by_an_editor
310
320
  }
311
321
  )
312
322
 
@@ -338,7 +348,7 @@ static func _opened_by_a_server() -> bool:
338
348
  ## collided with the editor it was moved away from. Restarting is done by starting the editor again
339
349
  ## with the same arguments now, which is `editor_launch restart`, so there is nothing to persist.
340
350
  func _serves(variable: String, setting: String) -> int:
341
- var asked: int = _asked_for(variable)
351
+ var asked: int = 0 if _started_by_an_editor else _asked_for(variable)
342
352
  return asked if asked > 0 else _serving(setting)
343
353
 
344
354
 
@@ -416,8 +416,14 @@ func _send_error(client: StreamPeerTCP, request_id: Variant, message: String) ->
416
416
  _send_response(client, {"type": "error", "message": message, "id": request_id})
417
417
 
418
418
 
419
+ ## Down with the window only when the close is taken. A game that asks before quitting turns
420
+ ## auto_accept_quit off and decides for itself, and one whose player answered No went on running
421
+ ## with no runtime and no announcement, so every later call said no game was running. A quit it
422
+ ## does take ends the tree, which [method _exit_tree] cleans up after.
419
423
  func _notification(what: int) -> void:
420
- if what == NOTIFICATION_WM_CLOSE_REQUEST:
424
+ if what != NOTIFICATION_WM_CLOSE_REQUEST:
425
+ return
426
+ if not is_inside_tree() or get_tree().auto_accept_quit:
421
427
  _cleanup()
422
428
 
423
429
 
@@ -13,7 +13,7 @@ func _init(host: Node) -> void:
13
13
 
14
14
 
15
15
  func capture_screenshot(params: Dictionary) -> Dictionary:
16
- return await _capture(_host.get_tree().root, params)
16
+ return await _capture(_host.get_tree().root, params, true)
17
17
 
18
18
 
19
19
  func capture_viewport(params: Dictionary) -> Dictionary:
@@ -28,11 +28,53 @@ func capture_viewport(params: Dictionary) -> Dictionary:
28
28
  if not node is Viewport:
29
29
  return {"type": "error", "message": "Node is not a Viewport: " + viewport_path}
30
30
  var viewport: Viewport = node
31
- return await _capture(viewport, params)
31
+ if not viewport is SubViewport:
32
+ return await _capture(viewport, params)
33
+ # Drawn now rather than taken as it stands. A SubViewport the game does not redraw every frame
34
+ # (disabled, a spent update-once, or update-when-visible behind a hidden container) keeps the
35
+ # last frame it drew, and that frame was answered as the viewport now. One update is asked for
36
+ # and the game's own mode put back after it.
37
+ var sub: SubViewport = viewport
38
+ var mode: SubViewport.UpdateMode = sub.render_target_update_mode
39
+ # Nothing is drawn without a window, so the frame waited for below may never come; the capture
40
+ # refuses that state itself.
41
+ if mode == SubViewport.UPDATE_ALWAYS or not _host.get_tree().root.can_draw():
42
+ return await _capture(viewport, params)
43
+ sub.render_target_update_mode = SubViewport.UPDATE_ONCE
44
+ await RenderingServer.frame_post_draw
45
+ var answer: Dictionary = await _capture(viewport, params)
46
+ sub.render_target_update_mode = mode
47
+ return answer
48
+
49
+
50
+ ## Lays the game's own windows that the root viewport does not draw over [param image], where
51
+ ## they sit on screen.
52
+ ##
53
+ ## With subwindows not embedded, a dialog or a menu's popup is a window of the operating system's
54
+ ## with a render target of its own, so a screenshot of the root viewport was the screen without the
55
+ ## dialog the game was showing. In tree order, so a window added later lies over an earlier one.
56
+ func _lay_native_windows(image: Image) -> void:
57
+ var root: Window = _host.get_tree().root
58
+ if root.gui_embed_subwindows:
59
+ return
60
+ var pending: Array[Node] = [root]
61
+ while not pending.is_empty():
62
+ var node: Node = pending.pop_back()
63
+ var children: Array[Node] = node.get_children(true)
64
+ for index: int in range(children.size() - 1, -1, -1):
65
+ pending.append(children[index])
66
+ if node == root or not node is Window:
67
+ continue
68
+ var window: Window = node
69
+ if not window.visible or window.is_embedded():
70
+ continue
71
+ var drawn: Image = window.get_texture().get_image()
72
+ if drawn == null or drawn.is_empty():
73
+ continue
74
+ drawn.convert(image.get_format())
75
+ image.blend_rect(drawn, Rect2i(Vector2i.ZERO, drawn.get_size()), window.position - root.position)
32
76
 
33
77
 
34
- ## The server names the file, so a game cannot point it at a path of its own choosing; a call
35
- ## with no path is a call the server did not make.
36
78
  ## The size a capture drawn at [param drawn] is scaled to, from the width and height asked for, 0
37
79
  ## being not asked. One side alone keeps the picture's proportions, since that is what asking for a
38
80
  ## smaller picture means: it was ignored unless both came, so the full-size picture was sent back.
@@ -46,7 +88,9 @@ static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
46
88
  return drawn
47
89
 
48
90
 
49
- func _capture(viewport: Viewport, params: Dictionary) -> Dictionary:
91
+ ## The server names the file, so a game cannot point it at a path of its own choosing; a call
92
+ ## with no path is a call the server did not make.
93
+ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false) -> Dictionary:
50
94
  var requested_path: String = str(params.get("output_path", ""))
51
95
  if requested_path.is_empty():
52
96
  return {"type": "error", "message": "output_path required"}
@@ -80,6 +124,8 @@ func _capture(viewport: Viewport, params: Dictionary) -> Dictionary:
80
124
  var image: Image = viewport_texture.get_image()
81
125
  if image == null:
82
126
  return {"type": "error", "message": "Failed to capture viewport image"}
127
+ if with_windows:
128
+ _lay_native_windows(image)
83
129
 
84
130
  var drawn: Vector2i = Vector2i(image.get_width(), image.get_height())
85
131
  var target: Vector2i = scaled_to(
@@ -132,7 +132,7 @@ func set_property(params: Dictionary) -> Dictionary:
132
132
  #
133
133
  # Judged by what reads back alone, so a value of another kind than the one held, which cannot be
134
134
  # compared with it, is still caught when the engine drops it.
135
- var already: bool = _held_already(given, old_value)
135
+ var already: bool = held_already(given, old_value)
136
136
  var unmoved: bool = Values.comparable(now, old_value) and now == old_value
137
137
  if unmoved and not already:
138
138
  var why: String = (
@@ -187,7 +187,7 @@ func set_property(params: Dictionary) -> Dictionary:
187
187
  ## property whose setter ignored a value differing from the held one below 32 bits: it is answered
188
188
  ## unchanged rather than refused. Vectors and colours hold 32-bit parts in the value itself, so they
189
189
  ## already compare at the property's precision.
190
- static func _held_already(given: Variant, held: Variant) -> bool:
190
+ static func held_already(given: Variant, held: Variant) -> bool:
191
191
  if not Values.comparable(given, held):
192
192
  return false
193
193
  if given == held:
@@ -67,6 +67,9 @@ const PACKED: Array[int] = [
67
67
  ## The calls an empty list has nothing to answer, which the engine reports as an error when asked.
68
68
  const NEEDS_AN_ELEMENT: Array[String] = ["front", "back", "pick_random"]
69
69
 
70
+ ## Said after every call a path refuses for doing something rather than reading.
71
+ const READS_ONLY: String = ", and a path, which a wait reads every frame, only calls methods that read"
72
+
70
73
 
71
74
  ## What [param reaching] names something on, which is the node itself until the name has a colon
72
75
  ## in it, and the last name along that path.
@@ -203,7 +206,7 @@ static func can_read(holder: Variant, named: String) -> bool:
203
206
  var object: Object = holder
204
207
  var method: String = method_of(named)
205
208
  if not method.is_empty():
206
- return _arguments_required(object, method) == 0
209
+ return call_refused(object, method, "").is_empty()
207
210
  return _has_property(object, named)
208
211
  return false
209
212
 
@@ -245,6 +248,62 @@ static func _arguments_required(object: Object, method: String) -> int:
245
248
  return -1
246
249
 
247
250
 
251
+ ## Why a path may not call [param method] on [param object], said about [param called_as], or ""
252
+ ## when it may.
253
+ ##
254
+ ## A path is read, and a wait reads it every frame, so a call in one has to be a read. The engine
255
+ ## marks its own reading methods const and `queue_free()`, `free()` and `set_name()` not, measured on
256
+ ## 4.7.2. A game's methods carry no such mark, so one declared `-> void` is taken at its word, as
257
+ ## doing something and answering nothing, and one left untyped is trusted. Without this a find
258
+ ## with property "queue_free()" freed every node it matched, and a wait on "advance_day()" called
259
+ ## it once a frame and then reported the state it had driven the game to.
260
+ static func call_refused(object: Object, method: String, called_as: String) -> String:
261
+ var required: int = _arguments_required(object, method)
262
+ if required == -1:
263
+ return "%s has no method %s" % [called_as, method]
264
+ if required > 0:
265
+ return (
266
+ "%s.%s takes %d argument%s, and a path can only call a method that takes none"
267
+ % [called_as, method, required, "" if required == 1 else "s"]
268
+ )
269
+ var scripted: Dictionary = _script_method(object, method)
270
+ if not scripted.is_empty():
271
+ var returned: Dictionary = scripted.get("return", {})
272
+ var usage: int = returned.get("usage", 0)
273
+ var type: int = returned.get("type", TYPE_NIL)
274
+ if type == TYPE_NIL and usage & PROPERTY_USAGE_NIL_IS_VARIANT == 0:
275
+ return (
276
+ "%s.%s is declared -> void, so it does something rather than answering%s"
277
+ % [called_as, method, READS_ONLY]
278
+ )
279
+ return ""
280
+ if _native_flags(object, method) & METHOD_FLAG_CONST == 0:
281
+ return "%s.%s changes what it is called on rather than reading it%s" % [called_as, method, READS_ONLY]
282
+ return ""
283
+
284
+
285
+ ## The entry a script attached to [param object] declares for [param method], or an empty one when
286
+ ## the method is the engine's.
287
+ static func _script_method(object: Object, method: String) -> Dictionary:
288
+ var attached: Variant = object.get_script()
289
+ if not attached is Script:
290
+ return {}
291
+ var script: Script = attached
292
+ for entry: Dictionary in script.get_script_method_list():
293
+ if str(entry.get("name", "")) == method:
294
+ return entry
295
+ return {}
296
+
297
+
298
+ ## The flags the engine declares [param method] with on [param object].
299
+ static func _native_flags(object: Object, method: String) -> int:
300
+ for entry: Dictionary in object.get_method_list():
301
+ if str(entry.get("name", "")) == method:
302
+ var flags: int = entry.get("flags", 0)
303
+ return flags
304
+ return 0
305
+
306
+
248
307
  ## Put [param value] where [param named] points. Only ever called once [method can_read] agrees,
249
308
  ## and lists and maps are reference types, so writing into one reaches the game's own copy.
250
309
  static func write(holder: Variant, named: String, value: Variant) -> void:
@@ -343,13 +402,7 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
343
402
  var object: Object = holder
344
403
  var method: String = method_of(named)
345
404
  if not method.is_empty():
346
- var required: int = _arguments_required(object, method)
347
- if required == -1:
348
- return "%s has no method %s" % [called_as, method]
349
- return (
350
- "%s.%s takes %d argument%s, and a path can only call a method that takes none"
351
- % [called_as, method, required, "" if required == 1 else "s"]
352
- )
405
+ return call_refused(object, method, called_as)
353
406
  # A method spelled as a property is the likeliest thing behind a name the object has no property
354
407
  # for, and the caller is one pair of brackets away from what they meant.
355
408
  if object.has_method(named):
@@ -14,7 +14,6 @@ const Words = preload("runtime_words.gd")
14
14
  ## The most nodes one find answers with, unless asked for fewer: enough for any real query and
15
15
  ## far short of the tree dump a query exists to avoid.
16
16
  const FIND_LIMIT: int = 100
17
- const FIND_LIMIT_CEILING: int = 1000
18
17
 
19
18
  ## What a find can be narrowed by, which is also what it refuses to answer without. Named once so
20
19
  ## the refusal lists the same set the walk reads.
@@ -24,6 +23,11 @@ const FILTERS: PackedStringArray = ["class", "script", "name", "group", "says"]
24
23
  ## thousand is a tree somebody pointed this at by mistake.
25
24
  const READ_LIMIT: int = 500
26
25
 
26
+ ## The most either can be asked for. A limit above it is refused rather than cut to it: the ceiling
27
+ ## was the default for a read, so the remedy its own answer gave, raising the limit, changed nothing
28
+ ## and said nothing, and a caller told to raise a number that stays put concludes the screen moved.
29
+ const LIMIT_CEILING: int = 5000
30
+
27
31
  var _host: Node
28
32
  var _values: Values
29
33
 
@@ -88,7 +92,11 @@ func find_nodes(params: Dictionary) -> Dictionary:
88
92
  wanted[filter] = str(params.get(filter, ""))
89
93
  var wanted_property: String = str(params.get("property", ""))
90
94
  var include_hidden: bool = Read.as_bool(params.get("include_hidden", true), true)
91
- var limit: int = clampi(Read.as_int(params.get("limit", FIND_LIMIT), FIND_LIMIT), 1, FIND_LIMIT_CEILING)
95
+ var limit: int = Read.as_int(params.get("limit", FIND_LIMIT), FIND_LIMIT)
96
+ if limit < 1 or limit > LIMIT_CEILING:
97
+ return {
98
+ "type": "error", "message": "limit is from 1 to %d, and %d was asked for" % [LIMIT_CEILING, limit]
99
+ }
92
100
 
93
101
  if not _anything_asked(wanted):
94
102
  return {"type": "error", "message": "find_nodes needs at least one of " + ", ".join(FILTERS)}
@@ -116,6 +124,10 @@ func find_nodes(params: Dictionary) -> Dictionary:
116
124
  for filter: String in wanted:
117
125
  widened[filter] = widened_says if filter == "says" else wanted[filter]
118
126
  var nearly_said: int = 0
127
+ # The near misses the same find would leave out as hidden, kept apart: the note says what a
128
+ # query would find, and counting these in it named a query that, asked, found none of them.
129
+ var nearly_hidden: int = 0
130
+ var nearly_said_hidden: int = 0
119
131
  var hidden: int = 0
120
132
  while not pending.is_empty():
121
133
  var node: Node = pending.pop_front()
@@ -129,13 +141,19 @@ func find_nodes(params: Dictionary) -> Dictionary:
129
141
  else:
130
142
  found.append(_found(node, wanted_property))
131
143
  elif rest and literal and str(node.name).containsn(wanted["name"]):
132
- nearly += 1
144
+ if include_hidden or shown(node):
145
+ nearly += 1
146
+ else:
147
+ nearly_hidden += 1
133
148
  elif (
134
149
  not widened_says.is_empty()
135
150
  and _named(node, wanted["name"])
136
151
  and _matches_apart_from_name(node, widened)
137
152
  ):
138
- nearly_said += 1
153
+ if include_hidden or shown(node):
154
+ nearly_said += 1
155
+ else:
156
+ nearly_said_hidden += 1
139
157
  # Internal children included, which they were not. A ConfirmationDialog builds its Yes and
140
158
  # its No as internal nodes, and a ScrollContainer its bars, so a find over a screen for
141
159
  # every Button came back without the two buttons the player is being asked to press:
@@ -151,21 +169,39 @@ func find_nodes(params: Dictionary) -> Dictionary:
151
169
  # Nothing found is the one answer that cannot be told apart from having asked the wrong
152
170
  # question, and a name written without a wildcard is the way an agent writes "contains".
153
171
  # Said only when it changes the answer, so a genuine nothing stays a plain nothing.
154
- if found.is_empty() and nearly > 0:
155
- var holding: String = "names contain" if nearly > 1 else "name contains"
156
- notes.append(
157
- (
158
- 'name is matched as a glob against the whole name; %d node %s "%s", which "*%s*" would find'
159
- % [nearly, holding, wanted["name"], wanted["name"]]
172
+ if found.is_empty() and nearly + nearly_hidden > 0:
173
+ var named_near: int = nearly if nearly > 0 else nearly_hidden
174
+ var holding: String = "names contain" if named_near > 1 else "name contains"
175
+ (
176
+ notes
177
+ . append(
178
+ (
179
+ 'name is matched as a glob against the whole name; %d %snode %s "%s", which "*%s*"%s would find'
180
+ % [
181
+ named_near,
182
+ "" if nearly > 0 else "hidden ",
183
+ holding,
184
+ wanted["name"],
185
+ wanted["name"],
186
+ "" if nearly > 0 else " with includeHidden true",
187
+ ]
188
+ )
160
189
  )
161
190
  )
162
- if found.is_empty() and nearly_said > 0:
191
+ if found.is_empty() and nearly_said + nearly_said_hidden > 0:
192
+ var said_near: int = nearly_said if nearly_said > 0 else nearly_said_hidden
163
193
  (
164
194
  notes
165
195
  . append(
166
196
  (
167
- 'says with a wildcard is matched as a glob against a node\'s whole text; "%s" would find %d node%s'
168
- % [widened_says, nearly_said, "" if nearly_said == 1 else "s"]
197
+ 'says with a wildcard is matched as a glob against a node\'s whole text; "%s"%s would find %d %snode%s'
198
+ % [
199
+ widened_says,
200
+ "" if nearly_said > 0 else " with includeHidden true",
201
+ said_near,
202
+ "" if nearly_said > 0 else "hidden ",
203
+ "" if said_near == 1 else "s",
204
+ ]
169
205
  )
170
206
  )
171
207
  )
@@ -184,17 +220,52 @@ func find_nodes(params: Dictionary) -> Dictionary:
184
220
  return answer
185
221
 
186
222
 
187
- ## Whether the player can see [param node], its ancestors counted.
223
+ ## Whether the player can see [param node], by the rule the engine draws by.
188
224
  ##
189
- ## Not [method CanvasItem.is_visible_in_tree] on its own, because only the nodes that draw have it:
190
- ## a plain Node sitting between a hidden panel and a label has no visibility to ask about, and the
191
- ## label answers that it is visible while nothing of it is on screen. Walking up is what makes a
192
- ## row hidden because the panel holding it is hidden, which is what somebody checking a screen is
193
- ## asking about. A wait on words asks the same, so it reads this too.
225
+ ## A CanvasItem's visibility comes down through CanvasItem parents only, and one whose parent is not
226
+ ## a CanvasItem starts again from its canvas: measured on 4.7.2, a Label under a plain Node under a
227
+ ## hidden panel, and one under a hidden Node3D, both answer visible in tree, while one straight under
228
+ ## the panel does not. Walking every ancestor called those two hidden, so their words were left off
229
+ ## a screen that showed them and a wait on them ran out. What a CanvasItem's rule leaves out is its
230
+ ## canvas: a hidden layer hides everything drawn on it while each item keeps its own flag, and a
231
+ ## hidden window everything in it. A wait on words asks the same, so it reads this too.
194
232
  static func shown(node: Node) -> bool:
233
+ var item: CanvasItem = node as CanvasItem
234
+ if item != null:
235
+ return item.is_visible_in_tree() and _on_a_shown_canvas(item)
236
+ var spatial: Node3D = node as Node3D
237
+ if spatial != null:
238
+ return spatial.is_visible_in_tree() and _in_shown_windows(spatial)
239
+ var layer: CanvasLayer = node as CanvasLayer
240
+ if layer != null:
241
+ return layer.visible and _in_shown_windows(layer)
242
+ var window: Window = node as Window
243
+ if window != null:
244
+ return window.visible and _in_shown_windows(window.get_parent())
245
+ # A node that draws nothing is shown when what it sits in is.
246
+ var parent: Node = node.get_parent()
247
+ return parent == null or shown(parent)
248
+
249
+
250
+ ## Whether the canvas [param item] is drawn on is showing: its layer, and the windows around it.
251
+ static func _on_a_shown_canvas(item: CanvasItem) -> bool:
252
+ var walk: Node = item.get_parent()
253
+ while walk != null:
254
+ var layer: CanvasLayer = walk as CanvasLayer
255
+ if layer != null:
256
+ return layer.visible and _in_shown_windows(layer)
257
+ if walk is Viewport:
258
+ return _in_shown_windows(walk)
259
+ walk = walk.get_parent()
260
+ return true
261
+
262
+
263
+ ## Whether every window [param node] sits in is showing, itself included when it is one.
264
+ static func _in_shown_windows(node: Node) -> bool:
195
265
  var walk: Node = node
196
266
  while walk != null:
197
- if not _drawn(walk):
267
+ var window: Window = walk as Window
268
+ if window != null and not window.visible:
198
269
  return false
199
270
  walk = walk.get_parent()
200
271
  return true
@@ -304,7 +375,11 @@ static func _script_is(attached: Script, wanted: String) -> bool:
304
375
  func read_text(params: Dictionary) -> Dictionary:
305
376
  var root_path: String = str(params.get("root", "/root"))
306
377
  var include_hidden: bool = Read.as_bool(params.get("include_hidden", false))
307
- var limit: int = clampi(Read.as_int(params.get("limit", READ_LIMIT), READ_LIMIT), 1, READ_LIMIT)
378
+ var limit: int = Read.as_int(params.get("limit", READ_LIMIT), READ_LIMIT)
379
+ if limit < 1 or limit > LIMIT_CEILING:
380
+ return {
381
+ "type": "error", "message": "limit is from 1 to %d, and %d was asked for" % [LIMIT_CEILING, limit]
382
+ }
308
383
 
309
384
  var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
310
385
  if reached.has("message"):
@@ -338,41 +413,22 @@ func read_text(params: Dictionary) -> Dictionary:
338
413
  ## until it is opened, and reading a closed menu would put every item on the screen.
339
414
  ## Fills [param into] up to [param most] lines and answers with how many further lines the rest of
340
415
  ## the subtree says, which is what the caller is told rather than left to infer.
416
+ ##
417
+ ## Asked of each node rather than cut off at the first hidden one, since a hidden panel does not
418
+ ## hide a label reached through a plain Node: see [method shown].
341
419
  func _read_into(node: Node, include_hidden: bool, most: int, into: Array[String]) -> int:
342
- if not include_hidden and not _drawn(node):
343
- return 0
344
420
  var left_out: int = 0
345
- for said: String in Words.lines_said_by(node):
346
- if into.size() < most:
347
- into.append(said)
348
- else:
349
- left_out += 1
421
+ if include_hidden or shown(node):
422
+ for said: String in Words.lines_said_by(node):
423
+ if into.size() < most:
424
+ into.append(said)
425
+ else:
426
+ left_out += 1
350
427
  for child: Node in node.get_children(true):
351
428
  left_out += _read_into(child, include_hidden, most, into)
352
429
  return left_out
353
430
 
354
431
 
355
- ## Whether [param node] is on the screen at all, for the three kinds of thing that can be hidden.
356
- ##
357
- ## A Node3D among them because a hidden one draws nothing, Label3D included: reading a screen or
358
- ## finding what is on it counted a hidden 3D subtree as showing, which is the one answer neither
359
- ## question wants.
360
- static func _drawn(node: Node) -> bool:
361
- var control: CanvasItem = node as CanvasItem
362
- if control != null:
363
- return control.visible
364
- # A hidden layer hides everything drawn on it, a pause menu's for one, while each of those keeps
365
- # its own visible flag, so read as shown it matched the words of a menu nobody could see.
366
- var layer: CanvasLayer = node as CanvasLayer
367
- if layer != null:
368
- return layer.visible
369
- var spatial: Node3D = node as Node3D
370
- if spatial != null:
371
- return spatial.visible
372
- var window: Window = node as Window
373
- return window == null or window.visible
374
-
375
-
376
432
  ## Where a node is on screen: a Control's rectangle, a Node2D's position, or the place a 3D node
377
433
  ## is drawn in, in both the canvas coordinates the node reports and the window pixels input
378
434
  ## arrives in. The two differ whenever the project stretches its viewport, which is what made a
@@ -69,7 +69,6 @@ func wait_signal(params: Dictionary) -> Dictionary:
69
69
  return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
70
70
 
71
71
  var catcher: SignalCatcher = SignalCatcher.new()
72
- catcher.arity = _signal_arity(node, signal_name)
73
72
  var callable: Callable = catcher._on_fired
74
73
  var listening: Error = node.connect(signal_name, callable, CONNECT_ONE_SHOT)
75
74
  if listening != OK:
@@ -153,6 +152,8 @@ func wait_until(params: Dictionary) -> Dictionary:
153
152
  if holds_objects and Changes.names_no_object(wanted):
154
153
  wanted = null
155
154
  var refused: String = _not_comparable(current, wanted, node_path, property)
155
+ if refused.is_empty():
156
+ refused = _cut_to_fit(params["value"], typeof(current), node_path, property)
156
157
  var never_held: bool = typeof(wanted) != TYPE_NIL and typeof(wanted) != TYPE_OBJECT
157
158
  if refused.is_empty() and declared == TYPE_OBJECT and never_held:
158
159
  refused = (
@@ -169,9 +170,12 @@ func wait_until(params: Dictionary) -> Dictionary:
169
170
  var frames: int = 0
170
171
  # The type is checked every time round, not only at the top: a property that holds an object a
171
172
  # frame later is the same error arriving late.
173
+ # At the precision the property keeps, as set compares: a float arrives as 64 bits and most
174
+ # engine properties keep 32, so h_offset waited on as -1.1 read -1.10000002384186 every frame and
175
+ # the wait ran out on a value the game had reached.
172
176
  while (
173
177
  Values.comparable(current, wanted)
174
- and current != wanted
178
+ and not Changes.held_already(wanted, current)
175
179
  and Time.get_ticks_msec() - started < timeout_ms
176
180
  ):
177
181
  await _host.get_tree().process_frame
@@ -187,13 +191,42 @@ func wait_until(params: Dictionary) -> Dictionary:
187
191
  "type": "condition",
188
192
  "path": node_path,
189
193
  "property": property,
190
- "met": Values.comparable(current, wanted) and current == wanted,
194
+ "met": Changes.held_already(wanted, current),
191
195
  "value": _values.serialize(current),
192
196
  "elapsed_ms": Time.get_ticks_msec() - started,
193
197
  "frames": frames,
194
198
  }
195
199
 
196
200
 
201
+ ## Why [param given] would have to be cut to be waited on a whole number or a truth, or "" when not.
202
+ ##
203
+ ## A number arrives from JSON as a float, so one is converted to what the property holds, and the
204
+ ## conversion truncates: 3.5 waited on an int became 3 and 0.2 on a bool became true, and a property
205
+ ## already holding that answered met at once, about a state nobody asked for.
206
+ static func _cut_to_fit(given: Variant, type: int, node_path: String, property: String) -> String:
207
+ if type != TYPE_INT and type != TYPE_BOOL:
208
+ return ""
209
+ var number: Variant = given
210
+ if given is String:
211
+ var text: String = given
212
+ number = Read.json_or_null(text)
213
+ if typeof(number) != TYPE_FLOAT and typeof(number) != TYPE_INT:
214
+ return ""
215
+ var value: float = Read.as_float(number)
216
+ var whole: bool = value == floorf(value)
217
+ if type == TYPE_INT and whole:
218
+ return ""
219
+ if type == TYPE_BOOL and whole and (value == 0.0 or value == 1.0):
220
+ return ""
221
+ return (
222
+ (
223
+ "%s.%s holds %s and the value to wait for is %s, which it would have to be cut to fit:"
224
+ + " waiting on it would answer about a state nobody asked for."
225
+ )
226
+ % [node_path, property, type_string(type), str(given)]
227
+ )
228
+
229
+
197
230
  ## Why [param wanted] cannot be waited for against [param current], or "" when it can.
198
231
  ##
199
232
  ## Refused before anything is evaluated, because the evaluation is what does the damage: an object
@@ -342,26 +375,15 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
342
375
  return false
343
376
 
344
377
 
345
- func _signal_arity(node: Node, signal_name: String) -> int:
346
- for entry: Dictionary in node.get_signal_list():
347
- if entry.get("name", "") == signal_name:
348
- var declared: Array = entry.get("args", [])
349
- return declared.size()
350
- return 0
351
-
352
-
353
378
  ## Remembers that a signal fired and what it carried, for a wait that polls rather than
354
- ## awaits the signal directly, so the wait can also give up. The handler accepts up to five
355
- ## arguments, which covers every signal the engine declares, and records as many as the signal
356
- ## has.
379
+ ## awaits the signal directly, so the wait can also give up. Takes any number of arguments: a
380
+ ## handler of five fixed ones covered the engine's signals, and a game's own signal with six was
381
+ ## never delivered to it, so the wait ran out and said the signal had not fired.
357
382
  class SignalCatcher:
358
383
  extends RefCounted
359
384
  var fired: bool = false
360
- var arity: int = 0
361
385
  var args: Array = []
362
386
 
363
- func _on_fired(
364
- a: Variant = null, b: Variant = null, c: Variant = null, d: Variant = null, e: Variant = null
365
- ) -> void:
387
+ func _on_fired(...carried: Array) -> void:
366
388
  fired = true
367
- args = [a, b, c, d, e].slice(0, mini(arity, 5))
389
+ args = carried