gdharness 1.1.21 → 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.
Files changed (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ extends Logger
2
+
3
+ ## What the engine reports on the main thread while one call runs, added for that call alone.
4
+ ##
5
+ ## A call the engine refuses, given too few arguments or one it will not convert, comes back from
6
+ ## `callv` as null, which is also what a method that ran and returned nothing answers, and the
7
+ ## engine's reason goes only to the game's output. Measured on 4.7.2: each refusal is one error from
8
+ ## `callv` whose text begins with [constant REFUSED]. Anything else reported while the call runs is
9
+ ## the method's own, raised on its way through.
10
+
11
+ const REFUSED: String = "Error calling method from 'callv': "
12
+
13
+ ## The engine's reason for refusing the call, without [constant REFUSED], or "" when it ran.
14
+ var refused: String = ""
15
+ ## What was reported while it ran, a line each, warnings marked as such.
16
+ var raised: Array[String] = []
17
+
18
+
19
+ func _log_message(_message: String, _error: bool) -> void:
20
+ pass
21
+
22
+
23
+ # The engine's own signature for a Logger, eight parameters and not this project's to shape.
24
+ # gdlint:ignore = function-arguments-number
25
+ func _log_error(
26
+ function: String,
27
+ _file: String,
28
+ _line: int,
29
+ code: String,
30
+ rationale: String,
31
+ _editor_notify: bool,
32
+ error_type: int,
33
+ _script_backtraces: Array[ScriptBacktrace],
34
+ ) -> void:
35
+ # Another thread's report in the same moment is not the call's.
36
+ if OS.get_thread_caller_id() != OS.get_main_thread_id():
37
+ return
38
+ var text: String = rationale if not rationale.is_empty() else code
39
+ if function == "callv" and text.begins_with(REFUSED) and refused.is_empty():
40
+ refused = text.trim_prefix(REFUSED)
41
+ return
42
+ raised.append(("WARNING: " if error_type == ERROR_TYPE_WARNING else "") + text)
@@ -0,0 +1 @@
1
+ uid://b7rsw2ahrpmhl
@@ -52,6 +52,23 @@ static func as_float(value: Variant, fallback: float = 0.0) -> float:
52
52
  if value is String:
53
53
  var text: String = value
54
54
  return text.to_float()
55
+ if value is Dictionary:
56
+ var tagged: Dictionary = value
57
+ if str(tagged.get("_type", "")) == "float":
58
+ return non_finite(str(tagged.get("value", "")), fallback)
59
+ return fallback
60
+
61
+
62
+ ## The float a non-finite tag names, "inf", "-inf" or "nan", or [param fallback] for anything else.
63
+ ## JSON has no spelling for these, so the serialiser tags them rather than let them arrive as null.
64
+ static func non_finite(word: String, fallback: float = 0.0) -> float:
65
+ match word:
66
+ "inf":
67
+ return INF
68
+ "-inf":
69
+ return -INF
70
+ "nan":
71
+ return NAN
55
72
  return fallback
56
73
 
57
74
 
@@ -11,10 +11,12 @@ extends Node
11
11
  ## id and the reply carries it back.
12
12
 
13
13
  const Capture = preload("runtime_capture.gd")
14
+ const Changes = preload("runtime_changes.gd")
14
15
  const ErrorReport = preload("error_report.gd")
15
16
  const InputCommands = preload("runtime_input.gd")
16
17
  const Queries = preload("runtime_queries.gd")
17
18
  const Read = preload("reading.gd")
19
+ const Typing = preload("runtime_typing.gd")
18
20
  const Values = preload("runtime_values.gd")
19
21
  const Waits = preload("runtime_waits.gd")
20
22
 
@@ -48,7 +50,9 @@ var values: Values = Values.new()
48
50
  # rather than by reference, and a module referenced only by the Callables in the command table
49
51
  # would be freed on the way out of _init, leaving every command "unknown".
50
52
  var _queries: Queries = Queries.new(self, values)
53
+ var _changes: Changes = Changes.new(self, values)
51
54
  var _input: InputCommands = InputCommands.new(self, values)
55
+ var _typing: Typing = Typing.new(self)
52
56
  var _capture: Capture = Capture.new(self)
53
57
  var _waits: Waits = Waits.new(self, values)
54
58
 
@@ -82,18 +86,18 @@ func _init() -> void:
82
86
  "read_text": _queries.read_text,
83
87
  "get_rect": _queries.get_rect,
84
88
  "get_property": _queries.get_property,
85
- "set_property": _queries.set_property,
86
- "call_method": _queries.call_method,
89
+ "set_property": _changes.set_property,
90
+ "call_method": _changes.call_method,
87
91
  "get_metrics": _queries.get_metrics,
88
92
  "capture_screenshot": _capture.capture_screenshot,
89
93
  "capture_viewport": _capture.capture_viewport,
90
94
  "inject_action": _input.inject_action,
91
95
  "inject_key": _input.inject_key,
92
- "inject_text": _input.inject_text,
96
+ "inject_text": _typing.inject_text,
93
97
  "inject_mouse_click": _input.inject_mouse_click,
94
98
  "inject_mouse_motion": _input.inject_mouse_motion,
95
99
  "click": _input.click,
96
- "choose": _input.choose,
100
+ "choose": _typing.choose,
97
101
  "wait_frames": _waits.wait_frames,
98
102
  "wait_signal": _waits.wait_signal,
99
103
  "wait_until": _waits.wait_until,
@@ -412,8 +416,14 @@ func _send_error(client: StreamPeerTCP, request_id: Variant, message: String) ->
412
416
  _send_response(client, {"type": "error", "message": message, "id": request_id})
413
417
 
414
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.
415
423
  func _notification(what: int) -> void:
416
- 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:
417
427
  _cleanup()
418
428
 
419
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(
@@ -0,0 +1,440 @@
1
+ extends RefCounted
2
+
3
+ ## What the server changes in the running game: a property written, a method called. Both take values
4
+ ## from the wire, so both fit them to what the game declares and refuse what cannot fit, and both read
5
+ ## back what the engine did rather than trusting that it did it.
6
+
7
+ const CallErrors = preload("call_errors.gd")
8
+ const Paths = preload("runtime_paths.gd")
9
+ const Read = preload("reading.gd")
10
+ const Values = preload("runtime_values.gd")
11
+
12
+ ## The most errors a call's answer carries, which is enough to see what the method ran into and short
13
+ ## of a method reporting one per element of a list of thousands.
14
+ const ERRORS_KEPT: int = 20
15
+
16
+ var _host: Node
17
+ var _values: Values
18
+
19
+
20
+ ## The host is the autoload, which is how the tree is reached: it is not in one when the
21
+ ## modules are built, and a fixture may put it in one later.
22
+ func _init(host: Node, values: Values) -> void:
23
+ _host = host
24
+ _values = values
25
+
26
+
27
+ func set_property(params: Dictionary) -> Dictionary:
28
+ var node_path: String = str(params.get("path", ""))
29
+ var property: String = str(params.get("property", ""))
30
+ var value: Variant = params.get("value")
31
+
32
+ if node_path.is_empty() or property.is_empty():
33
+ return {"type": "error", "message": "Node path and property required"}
34
+
35
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
36
+ if standing.has("message"):
37
+ return standing
38
+ var node: Node = standing["node"]
39
+
40
+ # Through a path as well, for the reason [method Paths.walk_to] gives, and read back off the same
41
+ # holder afterwards: a set that does not take says so by answering with the old value, which is
42
+ # how a typed container refusing a write is told from one accepting it.
43
+ var reached: Dictionary = Paths.walk_to(node, node_path, property)
44
+ if reached.has("message"):
45
+ return reached
46
+
47
+ var holder: Variant = reached["holder"]
48
+ var named: String = reached["name"]
49
+ # A call can be walked through and read, and is not a place: what it returns is the method's
50
+ # to hand out, and writing "into" it would set nothing the game keeps.
51
+ if not Paths.method_of(named).is_empty():
52
+ return {
53
+ "type": "error",
54
+ "message":
55
+ (
56
+ "%s:%s is a call, and a call is not a place to write: name a property"
57
+ % [reached["called"], named]
58
+ )
59
+ }
60
+ if not Paths.can_read(holder, named):
61
+ return {"type": "error", "message": Paths.nothing_there(holder, named, str(reached["called"]))}
62
+ var old_value: Variant = Paths.read_under(holder, named)
63
+ # The same rule the call path holds: a value that cannot become what the property holds is
64
+ # refused rather than written. Writing it means the engine picks something, and what it picks
65
+ # for a word where a number goes is zero, which the answer then reports as the new value.
66
+ #
67
+ # A slot declaring nothing, an untyped or Variant property or an element of an untyped list, takes
68
+ # anything, and the engine stores what it is given there. What it holds now still says how a value
69
+ # of the same kind is meant, 5 into an int staying an int, and whether a path names an object, but
70
+ # a value of another kind is stored as sent: a word into one holding a number is the word, and a
71
+ # number into one holding a word is the number, not its text. The text null empties it when it
72
+ # holds an object or nothing, as a wait reads it on the same slot.
73
+ var slot: Dictionary = Values.slot_declared(holder, named)
74
+ var declared: int = slot["type"]
75
+ var declared_script: Script = slot["script"]
76
+ var wanted: int = declared if declared != TYPE_NIL else typeof(old_value)
77
+ var holds_objects: bool = (
78
+ declared == TYPE_OBJECT or (declared == TYPE_NIL and (old_value == null or old_value is Object))
79
+ )
80
+ var given: Variant
81
+ if holds_objects and names_no_object(value):
82
+ given = null
83
+ elif wanted == TYPE_OBJECT and (value is String or value is Dictionary):
84
+ var named_object: Dictionary = _object_named(
85
+ node, node_path, value, str(slot["class"]), declared_script
86
+ )
87
+ if named_object.has("message"):
88
+ return {
89
+ "type": "error",
90
+ "message": "%s.%s holds an object: %s" % [reached["called"], named, named_object["message"]]
91
+ }
92
+ given = named_object["object"]
93
+ else:
94
+ var as_sent: Variant = _values.fitted(value, TYPE_NIL)
95
+ if declared == TYPE_NIL and not Values.acceptable(as_sent, wanted):
96
+ given = as_sent
97
+ else:
98
+ given = _values.fitted(value, wanted)
99
+ if not Values.acceptable(given, wanted):
100
+ return {
101
+ "type": "error",
102
+ "message":
103
+ (
104
+ "%s.%s holds %s and the value given is %s, which cannot become one."
105
+ % [reached["called"], named, type_string(wanted), type_string(typeof(given))]
106
+ )
107
+ }
108
+ var typed: Dictionary = _values.typed_like(given, old_value, _element_object.bind(node, node_path))
109
+ if typed.has("message"):
110
+ return {
111
+ "type": "error",
112
+ "message":
113
+ "%s.%s is a typed %s: %s." % [reached["called"], named, _sort_of(old_value), typed["message"]]
114
+ }
115
+ given = typed["value"]
116
+ # The write alone, setter included, which is where a property's cost is.
117
+ var began: int = Time.get_ticks_usec()
118
+ Paths.write(holder, named, given)
119
+ var elapsed: int = Time.get_ticks_usec() - began
120
+ var now: Variant = Paths.read_under(holder, named)
121
+ # Read back rather than trusted. The engine drops a write it will not take without a word, and
122
+ # the answer then showed the old value as the new one, shaped as a success: a typed container
123
+ # refusing a plain list did exactly that. A value the property changed on the way in, a setter
124
+ # clamping it, still changed it, so only a write that left the property as it was is refused.
125
+ # And only one that asked for a change: the value the property already held, written again, left
126
+ # it as it was because nothing was asked of it.
127
+ #
128
+ # Said as what was seen, because two causes look the same from here: the engine refusing the write,
129
+ # and a setter that ran and left the value where it was, clamping it, say. The one this can name
130
+ # is an object slot checked only by the engine class a script extends (see
131
+ # [method Values.slot_declared]), where an object of that class with another script is refused.
132
+ #
133
+ # Judged by what reads back alone, so a value of another kind than the one held, which cannot be
134
+ # compared with it, is still caught when the engine drops it.
135
+ var already: bool = held_already(given, old_value)
136
+ var unmoved: bool = Values.comparable(now, old_value) and now == old_value
137
+ if unmoved and not already:
138
+ var why: String = (
139
+ "the write left it as it was, which is what the engine refusing it and a setter keeping the"
140
+ + " value both look like"
141
+ )
142
+ if declared == TYPE_OBJECT and given is Object and declared_script == null:
143
+ var refused_object: Object = given
144
+ why += (
145
+ (
146
+ ". %s is declared as %s, which is also how the engine names a script class with no"
147
+ + " class_name; if it is one, the object given has the right class and another script:"
148
+ + " it is %s"
149
+ )
150
+ % [named, str(slot["class"]), Values.class_of(refused_object)]
151
+ )
152
+ return {
153
+ "type": "error",
154
+ "message":
155
+ (
156
+ "%s.%s was given %s and reads %s afterwards, as it did before: %s."
157
+ % [
158
+ reached["called"],
159
+ named,
160
+ JSON.stringify(_values.serialize(given)),
161
+ JSON.stringify(_values.serialize(now)),
162
+ why,
163
+ ]
164
+ )
165
+ }
166
+
167
+ var answer: Dictionary = {
168
+ "type": "property_set",
169
+ "path": node_path,
170
+ "property": property,
171
+ "old_value": _values.serialize(old_value),
172
+ "new_value": _values.serialize(now),
173
+ "elapsed_usec": elapsed
174
+ }
175
+ if already and unmoved:
176
+ answer["unchanged"] = true
177
+ return answer
178
+
179
+
180
+ ## Whether [param given] is the value [param held] already is, at the precision the property keeps.
181
+ ##
182
+ ## A float arrives as 64 bits and most engine properties keep 32: Camera3D.h_offset given -1.1
183
+ ## reads back as -1.10000002384186, so comparing the two exactly took a write of the value already
184
+ ## held for one the engine refused (#780). Rounding both to 32 bits is exact for a 32-bit property.
185
+ ## A 64-bit one can take a write this calls the same and read back changed, and the caller calls a
186
+ ## write unchanged only when it reads back as it was. What that cannot tell apart is a 64-bit
187
+ ## property whose setter ignored a value differing from the held one below 32 bits: it is answered
188
+ ## unchanged rather than refused. Vectors and colours hold 32-bit parts in the value itself, so they
189
+ ## already compare at the property's precision.
190
+ static func held_already(given: Variant, held: Variant) -> bool:
191
+ if not Values.comparable(given, held):
192
+ return false
193
+ if given == held:
194
+ return true
195
+ if typeof(given) != TYPE_FLOAT or typeof(held) != TYPE_FLOAT:
196
+ return false
197
+ return PackedFloat32Array([given])[0] == PackedFloat32Array([held])[0]
198
+
199
+
200
+ func call_method(params: Dictionary) -> Dictionary:
201
+ var node_path: String = str(params.get("path", ""))
202
+ var method: String = str(params.get("method", ""))
203
+ var args: Array = params.get("args", [])
204
+
205
+ if node_path.is_empty() or method.is_empty():
206
+ return {"type": "error", "message": "Node path and method required"}
207
+
208
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
209
+ if standing.has("message"):
210
+ return standing
211
+ var node: Node = standing["node"]
212
+
213
+ # Through a path as well, for the reason [method Paths.walk_to] gives. What a game does hangs off its
214
+ # nodes as much as its state does, so reading `_game:run:day` while being unable to call
215
+ # `_game:run:advance` answers half of what a node holds and refuses the other half.
216
+ var reached: Dictionary = Paths.walk_to(node, node_path, method)
217
+ if reached.has("message"):
218
+ return reached
219
+
220
+ # A list or a map answers the calls a path makes on one, and nothing else: said in those terms
221
+ # rather than as "has no method", which reads as a misspelling of a method that was never going
222
+ # to be there, and with the element spelled out, since a method on what it holds is the usual aim.
223
+ if not reached["holder"] is Object:
224
+ var sort: String = "a list"
225
+ if reached["holder"] is Dictionary:
226
+ sort = "a map"
227
+ elif Paths.packed(reached["holder"]):
228
+ sort = "a packed list"
229
+ var spelled: String = str(reached["name"])
230
+ if Paths.method_of(spelled).is_empty():
231
+ spelled += "()"
232
+ if args.is_empty() and Paths.can_read(reached["holder"], spelled):
233
+ var read_from: int = Time.get_ticks_usec()
234
+ var answered: Variant = Paths.read_under(reached["holder"], spelled)
235
+ var read_for: int = Time.get_ticks_usec() - read_from
236
+ return {
237
+ "type": "method_result",
238
+ "path": node_path,
239
+ "method": method,
240
+ "result": _values.serialize(answered),
241
+ "elapsed_usec": read_for,
242
+ }
243
+ var why: String = (
244
+ "%s:%s takes no arguments" % [reached["called"], spelled]
245
+ if Paths.can_read(reached["holder"], spelled)
246
+ else Paths.nothing_there(reached["holder"], spelled, str(reached["called"]))
247
+ )
248
+ return {
249
+ "type": "error",
250
+ "message":
251
+ (
252
+ "%s. A method of what %s holds is called on the element, as in %s:0:%s"
253
+ % [why, sort, reached["called"], reached["name"]]
254
+ )
255
+ }
256
+ var holder: Object = reached["holder"]
257
+ var named: String = reached["name"]
258
+ # The method a call ends on is the one being called whether or not it carries the brackets a
259
+ # step along the way would: "get_viewport():gui_get_focus_owner()" is the same call as without
260
+ # the last pair.
261
+ if not Paths.method_of(named).is_empty():
262
+ named = Paths.method_of(named)
263
+ if not holder.has_method(named):
264
+ return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
265
+
266
+ # Checked before the call rather than left to it. `callv` raises inside the game when an
267
+ # argument cannot be converted, and an error raised in a game somebody is playing holds it at a
268
+ # debugger break: every later call then reports a game that is not responding, which points
269
+ # nowhere near the argument. A wrong argument costs a refusal, never the session's game.
270
+ var count: String = _argument_count_refused(holder, named, args.size())
271
+ if not count.is_empty():
272
+ return {"type": "error", "message": "%s.%s %s" % [reached["called"], named, count]}
273
+ var deserialized_args: Array = []
274
+ # The object arguments checked only against the engine class, by position from 1, for the refusal
275
+ # the engine may still make: see [method Values.slot_declared].
276
+ var by_engine_class: Dictionary = {}
277
+ for index: int in args.size():
278
+ var wants: int = _values.parameter_type(holder, named, index)
279
+ if wants == TYPE_OBJECT and (args[index] is String or args[index] is Dictionary):
280
+ var declared: String = _values.parameter_class(holder, named, index)
281
+ var named_object: Dictionary = _object_named(node, node_path, args[index], declared)
282
+ if named_object.has("message"):
283
+ return {
284
+ "type": "error",
285
+ "message":
286
+ "%s.%s argument %d: %s" % [reached["called"], named, index + 1, named_object["message"]]
287
+ }
288
+ var resolved: Object = named_object["object"]
289
+ if resolved != null and ClassDB.class_exists(declared):
290
+ by_engine_class[index + 1] = [declared, Values.class_of(resolved)]
291
+ deserialized_args.append(named_object["object"])
292
+ continue
293
+ var given: Variant = _values.fitted(args[index], wants)
294
+ if not Values.acceptable(given, wants):
295
+ return {
296
+ "type": "error",
297
+ "message":
298
+ (
299
+ "%s.%s takes %s as argument %d and was given %s, which cannot be converted."
300
+ % [reached["called"], named, type_string(wants), index + 1, type_string(typeof(given))]
301
+ )
302
+ }
303
+ # A typed list or map is built as one, since a plain one handed to such a parameter raises
304
+ # inside the game, which is what every check here exists to stop.
305
+ var container: Variant = _values.parameter_container(holder, named, index)
306
+ if container != null:
307
+ var typed: Dictionary = _values.typed_like(
308
+ given, container, _element_object.bind(node, node_path)
309
+ )
310
+ if typed.has("message"):
311
+ return {
312
+ "type": "error",
313
+ "message":
314
+ (
315
+ "%s.%s takes a typed %s as argument %d: %s."
316
+ % [reached["called"], named, _sort_of(container), index + 1, typed["message"]]
317
+ )
318
+ }
319
+ given = typed["value"]
320
+ deserialized_args.append(given)
321
+
322
+ # Timed around the call alone, since the bridge's round trip is a second or more and swamps what
323
+ # one click's work costs; the frame metrics read whichever frame is last, which is not this one.
324
+ # Listened to as well, because a call the engine refused returns null like one that ran.
325
+ var caught: CallErrors = CallErrors.new()
326
+ OS.add_logger(caught)
327
+ var began: int = Time.get_ticks_usec()
328
+ var result: Variant = holder.callv(named, deserialized_args)
329
+ var elapsed: int = Time.get_ticks_usec() - began
330
+ OS.remove_logger(caught)
331
+
332
+ if not caught.refused.is_empty():
333
+ var message: String = (
334
+ "%s.%s did not run: the engine refused the call: %s"
335
+ % [reached["called"], named, caught.refused.trim_suffix(".")]
336
+ )
337
+ var position: RegExMatch = RegEx.create_from_string(r"Cannot convert argument (\d+)").search(
338
+ caught.refused
339
+ )
340
+ if position != null and by_engine_class.has(int(position.get_string(1))):
341
+ var checked: Array = by_engine_class[int(position.get_string(1))]
342
+ message += (
343
+ (
344
+ ". The parameter is declared as %s, which is also how the engine names a script class"
345
+ + " with no class_name; if it is one, the object given has the right class and another"
346
+ + " script: it is %s"
347
+ )
348
+ % checked
349
+ )
350
+ return {"type": "error", "message": message + "."}
351
+
352
+ var answer: Dictionary = {
353
+ "type": "method_result",
354
+ "path": node_path,
355
+ "method": method,
356
+ "result": _values.serialize(result),
357
+ "elapsed_usec": elapsed
358
+ }
359
+ if not caught.raised.is_empty():
360
+ answer["errors"] = caught.raised.slice(0, ERRORS_KEPT)
361
+ if caught.raised.size() > ERRORS_KEPT:
362
+ answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
363
+ return answer
364
+
365
+
366
+ ## Why [param given] arguments are the wrong number for [param method], or "" when they are not.
367
+ ##
368
+ ## Asked before the call, because `callv` given the wrong number refuses inside the game and returns
369
+ ## null, and the method list says how many a method takes: its parameters less those with defaults,
370
+ ## up to all of them, or any number past that for one taking varargs.
371
+ static func _argument_count_refused(holder: Object, method: String, given: int) -> String:
372
+ for entry: Dictionary in holder.get_method_list():
373
+ if str(entry.get("name", "")) != method:
374
+ continue
375
+ var parameters: Array = entry.get("args", [])
376
+ var defaults: Array = entry.get("default_args", [])
377
+ var most: int = parameters.size()
378
+ var least: int = most - defaults.size()
379
+ var any_more: bool = (Read.as_int(entry.get("flags", 0), 0) & METHOD_FLAG_VARARG) != 0
380
+ if given >= least and (given <= most or any_more):
381
+ return ""
382
+ var takes: String = "%d" % least
383
+ if any_more:
384
+ takes = "at least %d" % least
385
+ elif most != least:
386
+ takes = "%d to %d" % [least, most]
387
+ var one: bool = least == 1 and (most == least or any_more)
388
+ return "takes %s argument%s and was given %d" % [takes, "" if one else "s", given]
389
+ return ""
390
+
391
+
392
+ ## [method _object_named] with the path first, which is the order [method Values.typed_like] calls
393
+ ## an element resolver in.
394
+ func _element_object(
395
+ given: Variant, declared: String, script: Script, node: Node, node_path: String
396
+ ) -> Dictionary:
397
+ return _object_named(node, node_path, given, declared, script)
398
+
399
+
400
+ ## "list" or "map", for a refusal about a typed container.
401
+ static func _sort_of(container: Variant) -> String:
402
+ return "list" if container is Array else "map"
403
+
404
+
405
+ ## The object [param given] names for a slot or a parameter declared as [param declared], as
406
+ ## `{"object": ...}`, or `{"message": ...}` saying why it names none that fits.
407
+ ##
408
+ ## JSON cannot carry an object, so one the game holds is named by its path, read from the node the
409
+ ## call or the write was made on, and handed over as that instance rather than a copy: a method
410
+ ## given a piece of the game's state acts on the piece the game keeps, and a slot written with one
411
+ ## holds the game's own.
412
+ ##
413
+ ## The text null names no object, which is what an object slot or parameter takes to be emptied. A
414
+ ## caller's null arrived as that text in #772 and #783, and was looked up as a node called "null".
415
+ ##
416
+ ## Checked against [param script] where the declaration hands one over, which only a typed container
417
+ ## does: see [method Values.is_a].
418
+ func _object_named(
419
+ node: Node, node_path: String, given: Variant, declared: String, script: Script = null
420
+ ) -> Dictionary:
421
+ if names_no_object(given):
422
+ return {"object": null}
423
+ var found: Dictionary = Paths.object_at(_host.get_tree().root, node, node_path, given)
424
+ if found.has("message"):
425
+ return found
426
+ var instance: Object = found["object"]
427
+ if script != null and not Values.is_a(instance, declared, script):
428
+ var wanted: String = "an instance of %s" % Values.script_named(script)
429
+ if not script.get_global_name().is_empty():
430
+ wanted = "a %s" % script.get_global_name()
431
+ return {"message": "%s is %s, not %s" % [str(given), Values.class_of(instance), wanted]}
432
+ if script == null and not declared.is_empty() and not Values.is_a(instance, declared):
433
+ return {"message": "%s is %s, not a %s" % [str(given), Values.class_of(instance), declared]}
434
+ return found
435
+
436
+
437
+ ## Whether [param given] is the text null, which is how a caller's null arrives. Public because a
438
+ ## wait reads the same text the same way.
439
+ static func names_no_object(given: Variant) -> bool:
440
+ return given is String and str(given).strip_edges() == "null"
@@ -0,0 +1 @@
1
+ uid://c4w4bcnxrw1tx