gdharness 1.2.24 → 1.2.26

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.
@@ -321,11 +321,25 @@ static func acceptable(value: Variant, type: int) -> bool:
321
321
  ## The conversion is also what restores a packed array's exact type. Those cross as the plain JSON
322
322
  ## lists they fit into, so what comes back from the wire is an Array, and the parameter is what says
323
323
  ## it was a PackedStringArray.
324
+ ##
325
+ ## A dictionary with no tag is read as the type wanted when it carries that type's fields, as
326
+ ## scene_node and resource_edit read one: runtime_invoke refused {"x": 140, "y": 300} for a Vector2
327
+ ## argument the editor tools took, so one value was right for one tool and wrong for the next. The
328
+ ## value as sent is what is tagged, since the builders read a nested vector from its own fields.
324
329
  func fitted(value: Variant, type: int) -> Variant:
325
330
  var rebuilt: Variant = deserialize_value(value)
326
331
  if type == TYPE_NIL or typeof(rebuilt) == type:
327
332
  return rebuilt
328
333
 
334
+ if value is Dictionary and type != TYPE_DICTIONARY:
335
+ var fields: Dictionary = value
336
+ if not fields.has("_type"):
337
+ var tagged: Dictionary = fields.duplicate()
338
+ tagged["_type"] = type_string(type)
339
+ var built: Variant = deserialize_value(tagged)
340
+ if typeof(built) == type:
341
+ return built
342
+
329
343
  if rebuilt is Array and LIST_TYPES.has(type):
330
344
  return type_convert(rebuilt, type)
331
345
 
@@ -348,6 +362,21 @@ func fitted(value: Variant, type: int) -> Variant:
348
362
  return type_convert(rebuilt, type)
349
363
 
350
364
 
365
+ ## How a value of [param type] is written to be read as one, for a refusal to name, or empty for a
366
+ ## type that is not built from fields. A refusal naming only the two types left a caller to find
367
+ ## the shape in the description of another op.
368
+ static func written_as(type: int) -> String:
369
+ var tag: String = type_string(type)
370
+ var alternatives: Array = REQUIRED.get(tag, [])
371
+ if alternatives.is_empty() or tag == "float" or tag == "Dictionary":
372
+ return ""
373
+ var keys: Array = alternatives[0]
374
+ var parts: Array[String] = []
375
+ for key: Variant in keys:
376
+ parts.append('"%s": ...' % key)
377
+ return "{%s}" % ", ".join(parts)
378
+
379
+
351
380
  ## Whether [param fields] carries every key of any one of [param alternatives].
352
381
  ##
353
382
  ## More than one spelling because a rectangle has two: the two corners it is answered with, and the
@@ -0,0 +1,290 @@
1
+ extends RefCounted
2
+
3
+ ## What the running game tells a screen reader: its accessibility tree, as UI Automation reads it.
4
+ ##
5
+ ## The engine builds the tree only when something asks for it, and nothing on gdharness's desktop
6
+ ## does, so a game's NOTIFICATION_ACCESSIBILITY_UPDATE never came and what its controls expose could
7
+ ## not be checked. The engine offers no way to read the tree back, so the question is put the way a
8
+ ## screen reader puts it: a UI Automation client started by the game lands on the game's own desktop,
9
+ ## its first question makes the engine build the tree, and what it reads is what a screen reader is
10
+ ## told, names left empty included.
11
+
12
+ const Read = preload("reading.gd")
13
+
14
+ ## How long the client keeps walking the tree for it to settle: the engine answers the first question
15
+ ## with its windows alone and fills them in over the next frames.
16
+ const SETTLE_MS: int = 8000
17
+ ## How long a window with nothing in it is walked before the client stops: a game building its tree
18
+ ## fills the window within a few frames of being asked, and one building none never does.
19
+ const EMPTY_MS: int = 4000
20
+ ## How long the client may take beyond that, starting PowerShell and loading UI Automation included,
21
+ ## before it is stopped and the call answers that it did not finish.
22
+ const START_MS: int = 15000
23
+ ## Deep enough for any interface a game builds, and inside the hundred levels ConvertTo-Json stops
24
+ ## at, which are two to a level of the tree.
25
+ const MOST_LEVELS: int = 40
26
+
27
+ ## The client, as PowerShell, with $Handles, $Out, $SettleMs, $EmptyMs and $MostLevels set before it. Each
28
+ ## element answers what UI Automation names its role, its name, its value where it has one, its
29
+ ## bounds in the window's pixels, and the actions a screen reader is offered on it.
30
+ const WALK: String = r"""
31
+ $ErrorActionPreference = 'Stop'
32
+ function Answer($value) {
33
+ [System.IO.File]::WriteAllText($Out, (ConvertTo-Json -InputObject $value -Depth 100 -Compress))
34
+ }
35
+ try {
36
+ Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
37
+ $walker = [System.Windows.Automation.TreeWalker]::RawViewWalker
38
+ function Patterned($element, $name) {
39
+ $pattern = ("System.Windows.Automation.$($name)Pattern" -as [type])::Pattern
40
+ $found = $null
41
+ if ($element.TryGetCurrentPattern($pattern, [ref]$found)) { return $found }
42
+ return $null
43
+ }
44
+ function Describe($element, $ox, $oy, $depth) {
45
+ $c = $element.Current
46
+ $role = $c.ControlType.ProgrammaticName -replace '^ControlType[.]', ''
47
+ $role = ($role -creplace '(?<=[a-z])(?=[A-Z])', '_').ToLowerInvariant()
48
+ $node = [ordered]@{ role = $role; name = $c.Name }
49
+ if ($c.HelpText) { $node.description = $c.HelpText }
50
+ $r = $c.BoundingRectangle
51
+ if (-not $r.IsEmpty) {
52
+ $node.bounds = [ordered]@{ x = [int]($r.X - $ox); y = [int]($r.Y - $oy) }
53
+ $node.bounds.width = [int]$r.Width
54
+ $node.bounds.height = [int]$r.Height
55
+ }
56
+ $actions = @()
57
+ if (Patterned $element 'Invoke') { $actions += 'click' }
58
+ $p = Patterned $element 'Toggle'
59
+ if ($p) { $actions += 'toggle'; $node.toggled = "$($p.Current.ToggleState)".ToLowerInvariant() }
60
+ $p = Patterned $element 'ExpandCollapse'
61
+ if ($p) {
62
+ $actions += 'expand_collapse'
63
+ $node.expanded = "$($p.Current.ExpandCollapseState)".ToLowerInvariant()
64
+ }
65
+ $p = Patterned $element 'SelectionItem'
66
+ if ($p) { $actions += 'select'; $node.selected = $p.Current.IsSelected }
67
+ $p = Patterned $element 'Value'
68
+ if ($p) {
69
+ $node.value = $p.Current.Value
70
+ if (-not $p.Current.IsReadOnly) { $actions += 'set_value' }
71
+ }
72
+ $p = Patterned $element 'RangeValue'
73
+ if ($p) {
74
+ $node.value = $p.Current.Value
75
+ $node.range = @($p.Current.Minimum, $p.Current.Maximum)
76
+ if (-not $p.Current.IsReadOnly) { $actions += 'set_value' }
77
+ }
78
+ if (Patterned $element 'ScrollItem') { $actions += 'scroll_into_view' }
79
+ if ($c.IsKeyboardFocusable) { $actions += 'focus' }
80
+ $node.actions = $actions
81
+ if ($c.HasKeyboardFocus) { $node.focused = $true }
82
+ if (-not $c.IsEnabled) { $node.disabled = $true }
83
+ if ($c.IsOffscreen) { $node.offscreen = $true }
84
+ $children = @()
85
+ if ($depth -lt $MostLevels) {
86
+ $child = $walker.GetFirstChild($element)
87
+ while ($null -ne $child) {
88
+ $children += , (Describe $child $ox $oy ($depth + 1))
89
+ $child = $walker.GetNextSibling($child)
90
+ }
91
+ }
92
+ $node.children = $children
93
+ return $node
94
+ }
95
+ function Count($node) {
96
+ $n = 1
97
+ foreach ($child in $node.children) { $n += Count $child }
98
+ return $n
99
+ }
100
+ $windows = @()
101
+ foreach ($handle in $Handles) {
102
+ $root = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr][long]$handle)
103
+ $r = $root.Current.BoundingRectangle
104
+ $deadline = [DateTime]::UtcNow.AddMilliseconds($SettleMs)
105
+ $empty = [DateTime]::UtcNow.AddMilliseconds($EmptyMs)
106
+ $tree = Describe $root $r.X $r.Y 0
107
+ $last = Count $tree
108
+ # Walked again until two walks in a row agree, since the first question is what makes the
109
+ # engine build the tree, which arrives over the frames after it.
110
+ while ([DateTime]::UtcNow -lt $deadline) {
111
+ Start-Sleep -Milliseconds 150
112
+ $tree = Describe $root $r.X $r.Y 0
113
+ $count = Count $tree
114
+ if ($count -gt 1 -and $count -eq $last) { break }
115
+ if ($count -eq 1 -and [DateTime]::UtcNow -gt $empty) { break }
116
+ $last = $count
117
+ }
118
+ $windows += , $tree
119
+ }
120
+ Answer @{ windows = $windows }
121
+ } catch {
122
+ Answer @{ error = "$_" }
123
+ }
124
+ """
125
+
126
+ var _host: Node
127
+
128
+
129
+ func _init(host: Node) -> void:
130
+ _host = host
131
+
132
+
133
+ ## The game's windows as a screen reader is told them, each a tree of elements.
134
+ func get_accessibility(_params: Dictionary) -> Dictionary:
135
+ if DisplayServer.get_name() == "headless":
136
+ return _refused(
137
+ (
138
+ "A headless run has no window, so a screen reader is told nothing: start the run with"
139
+ + " headless: false."
140
+ )
141
+ )
142
+ if OS.get_name() != "Windows":
143
+ return _refused(
144
+ (
145
+ (
146
+ "The tree is read through UI Automation, the interface a Windows screen reader uses, and this"
147
+ + " game runs on %s, whose screen readers ask it through another"
148
+ )
149
+ % OS.get_name()
150
+ )
151
+ )
152
+ var handles: Array[String] = []
153
+ var ids: Array[int] = []
154
+ for id: int in DisplayServer.get_window_list():
155
+ var handle: int = DisplayServer.window_get_native_handle(DisplayServer.WINDOW_HANDLE, id)
156
+ if handle != 0:
157
+ handles.append(str(handle))
158
+ ids.append(id)
159
+
160
+ var out: String = OS.get_temp_dir().path_join(
161
+ "gdharness-accessibility-%d-%d.json" % [OS.get_process_id(), Time.get_ticks_usec()]
162
+ )
163
+ var script: String = (
164
+ (
165
+ "$Handles = @(%s)\n$Out = '%s'\n$SettleMs = %d\n$EmptyMs = %d\n$MostLevels = %d\n"
166
+ % [", ".join(handles), out.replace("'", "''"), SETTLE_MS, EMPTY_MS, MOST_LEVELS]
167
+ )
168
+ + WALK
169
+ )
170
+ # The system's own PowerShell by its path, so a powershell.exe earlier on PATH is not what runs.
171
+ var shell: String = OS.get_environment("SystemRoot").path_join(
172
+ "System32/WindowsPowerShell/v1.0/powershell.exe"
173
+ )
174
+ var pid: int = (
175
+ OS
176
+ . create_process(
177
+ shell,
178
+ [
179
+ "-NoProfile",
180
+ "-NonInteractive",
181
+ "-EncodedCommand",
182
+ Marshalls.raw_to_base64(script.to_utf16_buffer()),
183
+ ]
184
+ )
185
+ )
186
+ if pid <= 0:
187
+ return _refused("PowerShell could not be started from %s to read the tree" % shell)
188
+ # Waited out a frame at a time rather than blocking: the client's questions are answered on the
189
+ # game's main thread, which a blocking wait would hold, and neither side would move.
190
+ var deadline: int = Time.get_ticks_msec() + SETTLE_MS + START_MS
191
+ while OS.is_process_running(pid):
192
+ if Time.get_ticks_msec() > deadline:
193
+ var _killed: Error = OS.kill(pid)
194
+ return _refused(
195
+ "UI Automation had not read the tree after %d ms, so it was stopped" % (SETTLE_MS + START_MS)
196
+ )
197
+ await _host.get_tree().process_frame
198
+
199
+ var text: String = FileAccess.get_file_as_string(out)
200
+ # Not checked: a file that is not there was never written, which the answer below says.
201
+ var _removed: Error = DirAccess.remove_absolute(out)
202
+ var read: Variant = Read.json_or_null(text)
203
+ if not read is Dictionary:
204
+ return _refused("UI Automation answered nothing readable: %s" % text.left(500))
205
+ var answer: Dictionary = read
206
+ if answer.has("error"):
207
+ return _refused("UI Automation could not read the game's windows: %s" % str(answer["error"]))
208
+
209
+ var windows: Array = answer.get("windows", [])
210
+ var elements: int = 0
211
+ var unnamed: int = 0
212
+ for at: int in windows.size():
213
+ var window: Dictionary = windows[at]
214
+ window["window_id"] = ids[at] if at < ids.size() else -1
215
+ elements += _count(window)
216
+ unnamed += _unnamed_text(window)
217
+ var result: Dictionary = {"windows": windows, "elements": elements}
218
+ if elements <= windows.size():
219
+ var setting: String = _setting()
220
+ # The command line's --accessibility is consumed by the engine and not handed on, so only
221
+ # the setting and a screen reader can say the tree is built; otherwise an empty window is
222
+ # most likely a game that builds none.
223
+ # 1 is a screen reader running, 0 none and -1 a platform that cannot say.
224
+ var read_aloud: bool = setting == "auto" and DisplayServer.accessibility_screen_reader_active() == 1
225
+ if setting != "always" and not read_aloud:
226
+ return _refused(
227
+ (
228
+ (
229
+ "UI Automation found nothing in the game's windows: the engine builds the tree a screen"
230
+ + " reader reads only with accessibility/general/accessibility_support at Always Active,"
231
+ + " or at Auto with a screen reader running when it starts, and this project's is %s."
232
+ + ' Start the run with engineArgs ["--accessibility", "always"].'
233
+ )
234
+ % setting
235
+ )
236
+ )
237
+ result["_note"] = (
238
+ "The windows hold nothing a screen reader is told: Controls are in the tree, and Node2D and"
239
+ + " Node3D are not unless the game creates elements for them."
240
+ )
241
+ elif unnamed > 0:
242
+ # Seen on a game naming its lines with accessibility_update_set_name: the text was there in
243
+ # the game and empty to the screen reader.
244
+ result["_note"] = (
245
+ (
246
+ "%d text element(s) on screen have no name, so a screen reader reads them as nothing. Static"
247
+ + " text is read from its value, DisplayServer.accessibility_update_set_value, and a name"
248
+ + " set on it is not read."
249
+ )
250
+ % unnamed
251
+ )
252
+ return result
253
+
254
+
255
+ ## The project's accessibility mode, as the command line's --accessibility names them.
256
+ static func _setting() -> String:
257
+ match Read.as_int(ProjectSettings.get_setting("accessibility/general/accessibility_support", 0), 0):
258
+ 1:
259
+ return "always"
260
+ 2:
261
+ return "disabled"
262
+ return "auto"
263
+
264
+
265
+ static func _count(element: Dictionary) -> int:
266
+ var total: int = 1
267
+ var children: Array = element.get("children", [])
268
+ for child: Variant in children:
269
+ if child is Dictionary:
270
+ var inner: Dictionary = child
271
+ total += _count(inner)
272
+ return total
273
+
274
+
275
+ ## Text elements with bounds and no name. A window also holds a nameless text element with no bounds
276
+ ## that no node of the game made, in every scene tried, which is left out.
277
+ static func _unnamed_text(element: Dictionary) -> int:
278
+ var total: int = 0
279
+ if element.get("role") == "text" and str(element.get("name", "")).is_empty() and element.has("bounds"):
280
+ total += 1
281
+ var children: Array = element.get("children", [])
282
+ for child: Variant in children:
283
+ if child is Dictionary:
284
+ var inner: Dictionary = child
285
+ total += _unnamed_text(inner)
286
+ return total
287
+
288
+
289
+ static func _refused(message: String) -> Dictionary:
290
+ return {"type": "error", "message": message}
@@ -10,10 +10,12 @@ extends Node
10
10
  ## wanted. Both directions of the socket carry one JSON object per line. Every request names an
11
11
  ## id and the reply carries it back.
12
12
 
13
+ const Accessibility = preload("runtime_accessibility.gd")
13
14
  const Capture = preload("runtime_capture.gd")
14
15
  const Changes = preload("runtime_changes.gd")
15
16
  const ErrorReport = preload("error_report.gd")
16
17
  const InputCommands = preload("runtime_input.gd")
18
+ const Pad = preload("runtime_pad.gd")
17
19
  const Queries = preload("runtime_queries.gd")
18
20
  const Read = preload("reading.gd")
19
21
  const Typing = preload("runtime_typing.gd")
@@ -85,8 +87,10 @@ var _queries: Queries = Queries.new(self, values)
85
87
  var _changes: Changes = Changes.new(self, values)
86
88
  var _input: InputCommands = InputCommands.new(self, values)
87
89
  var _typing: Typing = Typing.new(self)
90
+ var _pad: Pad = Pad.new(self)
88
91
  var _capture: Capture = Capture.new(self, values)
89
92
  var _waits: Waits = Waits.new(self, values)
93
+ var _accessibility: Accessibility = Accessibility.new(self)
90
94
 
91
95
  var _server: TCPServer
92
96
  var _clients: Array[StreamPeerTCP] = []
@@ -128,6 +132,7 @@ func _init() -> void:
128
132
  "set_property": _changes.set_property,
129
133
  "call_method": _changes.call_method,
130
134
  "get_metrics": _queries.get_metrics,
135
+ "get_accessibility": _accessibility.get_accessibility,
131
136
  "capture_screenshot": _capture.capture_screenshot,
132
137
  "capture_viewport": _capture.capture_viewport,
133
138
  "inject_action": _input.inject_action,
@@ -135,6 +140,8 @@ func _init() -> void:
135
140
  "inject_text": _typing.inject_text,
136
141
  "inject_mouse_click": _input.inject_mouse_click,
137
142
  "inject_mouse_motion": _input.inject_mouse_motion,
143
+ "inject_joypad_button": _pad.inject_joypad_button,
144
+ "inject_joypad_motion": _pad.inject_joypad_motion,
138
145
  "click": _input.click,
139
146
  "choose": _typing.choose,
140
147
  "wait_frames": _waits.wait_frames,
@@ -101,8 +101,14 @@ func set_property(params: Dictionary) -> Dictionary:
101
101
  "type": "error",
102
102
  "message":
103
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))]
104
+ "%s.%s holds %s and the value given is %s, which cannot become one.%s"
105
+ % [
106
+ reached["called"],
107
+ named,
108
+ type_string(wanted),
109
+ type_string(typeof(given)),
110
+ _written_as(wanted)
111
+ ]
106
112
  )
107
113
  }
108
114
  var typed: Dictionary = _values.typed_like(given, old_value, _element_object.bind(node, node_path))
@@ -301,8 +307,15 @@ func call_method(params: Dictionary) -> Dictionary:
301
307
  "type": "error",
302
308
  "message":
303
309
  (
304
- "%s.%s takes %s as argument %d and was given %s, which cannot be converted."
305
- % [reached["called"], named, type_string(wants), index + 1, type_string(typeof(given))]
310
+ "%s.%s takes %s as argument %d and was given %s, which cannot be converted.%s"
311
+ % [
312
+ reached["called"],
313
+ named,
314
+ type_string(wants),
315
+ index + 1,
316
+ type_string(typeof(given)),
317
+ _written_as(wants)
318
+ ]
306
319
  )
307
320
  }
308
321
  # A typed list or map is built as one, since a plain one handed to such a parameter raises
@@ -492,6 +505,13 @@ func _element_object(
492
505
 
493
506
 
494
507
  ## "list" or "map", for a refusal about a typed container.
508
+ ## The sentence a refusal ends with for a type built from fields, naming the shape that is read as
509
+ ## one, or nothing for a type that is not.
510
+ static func _written_as(type: int) -> String:
511
+ var shape: String = Values.written_as(type)
512
+ return "" if shape.is_empty() else " A %s is sent as %s." % [type_string(type), shape]
513
+
514
+
495
515
  static func _sort_of(container: Variant) -> String:
496
516
  return "list" if container is Array else "map"
497
517
 
@@ -0,0 +1,150 @@
1
+ extends RefCounted
2
+
3
+ ## What reaches the running game the way a controller reaches it: a button pressed and a stick or
4
+ ## trigger moved, as the events a pad sends.
5
+ ##
6
+ ## The events themselves rather than the actions bound to them: a game reading the pad in _input or
7
+ ## _unhandled_input looks for InputEventJoypadButton and InputEventJoypadMotion, and an action
8
+ ## reached it as an InputEventAction it never reads, so a controller layout could only be driven by
9
+ ## calling the game's own methods. The engine still fires the actions bound to a button from these.
10
+
11
+ const Read = preload("reading.gd")
12
+
13
+ ## Controller buttons by the names Godot gives them, for a caller naming one rather than numbering it.
14
+ const JOY_BUTTONS: Dictionary[String, int] = {
15
+ "a": JOY_BUTTON_A,
16
+ "b": JOY_BUTTON_B,
17
+ "x": JOY_BUTTON_X,
18
+ "y": JOY_BUTTON_Y,
19
+ "back": JOY_BUTTON_BACK,
20
+ "guide": JOY_BUTTON_GUIDE,
21
+ "start": JOY_BUTTON_START,
22
+ "left_stick": JOY_BUTTON_LEFT_STICK,
23
+ "right_stick": JOY_BUTTON_RIGHT_STICK,
24
+ "left_shoulder": JOY_BUTTON_LEFT_SHOULDER,
25
+ "right_shoulder": JOY_BUTTON_RIGHT_SHOULDER,
26
+ "dpad_up": JOY_BUTTON_DPAD_UP,
27
+ "dpad_down": JOY_BUTTON_DPAD_DOWN,
28
+ "dpad_left": JOY_BUTTON_DPAD_LEFT,
29
+ "dpad_right": JOY_BUTTON_DPAD_RIGHT,
30
+ "misc1": JOY_BUTTON_MISC1,
31
+ "paddle1": JOY_BUTTON_PADDLE1,
32
+ "paddle2": JOY_BUTTON_PADDLE2,
33
+ "paddle3": JOY_BUTTON_PADDLE3,
34
+ "paddle4": JOY_BUTTON_PADDLE4,
35
+ "touchpad": JOY_BUTTON_TOUCHPAD,
36
+ }
37
+
38
+ ## Stick and trigger axes by the names Godot gives them.
39
+ const JOY_AXES: Dictionary[String, int] = {
40
+ "left_x": JOY_AXIS_LEFT_X,
41
+ "left_y": JOY_AXIS_LEFT_Y,
42
+ "right_x": JOY_AXIS_RIGHT_X,
43
+ "right_y": JOY_AXIS_RIGHT_Y,
44
+ "trigger_left": JOY_AXIS_TRIGGER_LEFT,
45
+ "trigger_right": JOY_AXIS_TRIGGER_RIGHT,
46
+ }
47
+
48
+ var _host: Node
49
+
50
+
51
+ func _init(host: Node) -> void:
52
+ _host = host
53
+
54
+
55
+ ## Presses a controller button, and lets go of it again unless asked to hold it, as a key press does:
56
+ ## a button left down is a press that never ends, and everything reading it goes on seeing it.
57
+ func inject_joypad_button(params: Dictionary) -> Dictionary:
58
+ var given: Variant = params.get("button", null)
59
+ var button: int = _named_or_numbered(given, JOY_BUTTONS, JOY_BUTTON_MAX)
60
+ if button < 0:
61
+ return {"type": "error", "message": _not_one_of("button", given, JOY_BUTTONS, JOY_BUTTON_MAX)}
62
+ var device: int = Read.as_int(params.get("device", 0), 0)
63
+ var held: bool = Read.as_bool(params.get("pressed", true), true)
64
+ var whole: bool = not params.has("pressed")
65
+
66
+ Input.parse_input_event(_button(button, device, whole or held))
67
+ if whole:
68
+ # A frame between the halves, as a key has, so a game that acts on the press and one that
69
+ # acts on the release each get a frame to do it in.
70
+ await _host.get_tree().process_frame
71
+ Input.parse_input_event(_button(button, device, false))
72
+ await _host.get_tree().process_frame
73
+
74
+ return {
75
+ "type": "input_injected",
76
+ "input_type": "joypad_button",
77
+ "button": button,
78
+ "button_name": _name_of(button, JOY_BUTTONS),
79
+ "device": device,
80
+ "pressed": held and not whole,
81
+ "whole": whole,
82
+ }
83
+
84
+
85
+ ## Moves a stick or trigger axis to a value, where it stays until another motion moves it, as a
86
+ ## stick held over does.
87
+ func inject_joypad_motion(params: Dictionary) -> Dictionary:
88
+ var given: Variant = params.get("axis", null)
89
+ var axis: int = _named_or_numbered(given, JOY_AXES, JOY_AXIS_MAX)
90
+ if axis < 0:
91
+ return {"type": "error", "message": _not_one_of("axis", given, JOY_AXES, JOY_AXIS_MAX)}
92
+ if not params.has("value"):
93
+ return {"type": "error", "message": "joypad_motion needs value, where the axis is, -1 to 1"}
94
+ var value: float = Read.as_float(params.get("value"), 0.0)
95
+ if value < -1.0 or value > 1.0:
96
+ return {
97
+ "type": "error", "message": "value %s is outside -1 to 1, the whole travel of an axis" % value
98
+ }
99
+ var device: int = Read.as_int(params.get("device", 0), 0)
100
+
101
+ var event: InputEventJoypadMotion = InputEventJoypadMotion.new()
102
+ event.axis = axis as JoyAxis
103
+ event.axis_value = value
104
+ event.device = device
105
+ Input.parse_input_event(event)
106
+
107
+ return {
108
+ "type": "input_injected",
109
+ "input_type": "joypad_motion",
110
+ "axis": axis,
111
+ "axis_name": _name_of(axis, JOY_AXES),
112
+ "value": value,
113
+ "device": device,
114
+ }
115
+
116
+
117
+ static func _button(button: int, device: int, down: bool) -> InputEventJoypadButton:
118
+ var event: InputEventJoypadButton = InputEventJoypadButton.new()
119
+ event.button_index = button as JoyButton
120
+ event.pressed = down
121
+ event.device = device
122
+ return event
123
+
124
+
125
+ ## [param given] as one of [param names], or as a number below [param count], or -1 for neither.
126
+ static func _named_or_numbered(given: Variant, names: Dictionary[String, int], count: int) -> int:
127
+ if given is String:
128
+ var named: String = given
129
+ return Read.as_int(names.get(named.strip_edges().to_lower(), -1), -1)
130
+ if given is int or given is float:
131
+ var number: int = Read.as_int(given, -1)
132
+ return number if number >= 0 and number < count else -1
133
+ return -1
134
+
135
+
136
+ static func _not_one_of(
137
+ argument: String, given: Variant, names: Dictionary[String, int], count: int
138
+ ) -> String:
139
+ return (
140
+ "%s %s is not one: give its name, one of %s, or its number, 0 to %d"
141
+ % [argument, JSON.stringify(given), ", ".join(PackedStringArray(names.keys())), count - 1]
142
+ )
143
+
144
+
145
+ ## The name [param number] goes by in [param names], or empty for a number with none.
146
+ static func _name_of(number: int, names: Dictionary[String, int]) -> String:
147
+ for named: String in names:
148
+ if names[named] == number:
149
+ return named
150
+ return ""
@@ -0,0 +1 @@
1
+ uid://cj164fjnsnn1y
@@ -718,6 +718,11 @@ func get_metrics(params: Dictionary) -> Dictionary:
718
718
  "render_total_objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
719
719
  "render_total_primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
720
720
  "render_total_draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
721
+ # The GPU's half of the game's memory, in bytes, which memory_static does not count: a game
722
+ # committing 2.3 GB answered 298 MB there, and the rest could not be asked about.
723
+ "render_video_mem_used": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED),
724
+ "render_texture_mem_used": Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED),
725
+ "render_buffer_mem_used": Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED),
721
726
  }
722
727
 
723
728
  if metrics.is_empty():
@@ -65,6 +65,11 @@ static func acceptable(value: Variant, type: int) -> bool:
65
65
  return Serialisation.acceptable(value, type)
66
66
 
67
67
 
68
+ ## How a value of [param type] is written to be read as one, or empty when it is not built from fields.
69
+ static func written_as(type: int) -> String:
70
+ return Serialisation.written_as(type)
71
+
72
+
68
73
  ## The declared type of one parameter of a method, or TYPE_NIL when the method or the parameter
69
74
  ## is not there, which reads as "no opinion".
70
75
  func parameter_type(object: Object, method: String, index: int) -> int:
@@ -321,11 +321,25 @@ static func acceptable(value: Variant, type: int) -> bool:
321
321
  ## The conversion is also what restores a packed array's exact type. Those cross as the plain JSON
322
322
  ## lists they fit into, so what comes back from the wire is an Array, and the parameter is what says
323
323
  ## it was a PackedStringArray.
324
+ ##
325
+ ## A dictionary with no tag is read as the type wanted when it carries that type's fields, as
326
+ ## scene_node and resource_edit read one: runtime_invoke refused {"x": 140, "y": 300} for a Vector2
327
+ ## argument the editor tools took, so one value was right for one tool and wrong for the next. The
328
+ ## value as sent is what is tagged, since the builders read a nested vector from its own fields.
324
329
  func fitted(value: Variant, type: int) -> Variant:
325
330
  var rebuilt: Variant = deserialize_value(value)
326
331
  if type == TYPE_NIL or typeof(rebuilt) == type:
327
332
  return rebuilt
328
333
 
334
+ if value is Dictionary and type != TYPE_DICTIONARY:
335
+ var fields: Dictionary = value
336
+ if not fields.has("_type"):
337
+ var tagged: Dictionary = fields.duplicate()
338
+ tagged["_type"] = type_string(type)
339
+ var built: Variant = deserialize_value(tagged)
340
+ if typeof(built) == type:
341
+ return built
342
+
329
343
  if rebuilt is Array and LIST_TYPES.has(type):
330
344
  return type_convert(rebuilt, type)
331
345
 
@@ -348,6 +362,21 @@ func fitted(value: Variant, type: int) -> Variant:
348
362
  return type_convert(rebuilt, type)
349
363
 
350
364
 
365
+ ## How a value of [param type] is written to be read as one, for a refusal to name, or empty for a
366
+ ## type that is not built from fields. A refusal naming only the two types left a caller to find
367
+ ## the shape in the description of another op.
368
+ static func written_as(type: int) -> String:
369
+ var tag: String = type_string(type)
370
+ var alternatives: Array = REQUIRED.get(tag, [])
371
+ if alternatives.is_empty() or tag == "float" or tag == "Dictionary":
372
+ return ""
373
+ var keys: Array = alternatives[0]
374
+ var parts: Array[String] = []
375
+ for key: Variant in keys:
376
+ parts.append('"%s": ...' % key)
377
+ return "{%s}" % ", ".join(parts)
378
+
379
+
351
380
  ## Whether [param fields] carries every key of any one of [param alternatives].
352
381
  ##
353
382
  ## More than one spelling because a rectangle has two: the two corners it is answered with, and the
@@ -29,6 +29,10 @@ const ProjectDiagnostics = preload("project_diagnostics.gd")
29
29
  const RenameChecks = preload("rename_checks.gd")
30
30
  const ResourceFiles = preload("resource_files.gd")
31
31
 
32
+ ## Printed once the answer is written, after which the engine runs the project's autoloads. Kept in
33
+ ## step with `ANSWERED` in src/headless.ts.
34
+ const ANSWERED: String = "[gdharness] answered"
35
+
32
36
  var _log: Log
33
37
 
34
38
 
@@ -86,6 +90,9 @@ func _init() -> void:
86
90
  _log.error("Could not write the answer to " + answer_path)
87
91
  quit(1)
88
92
  return
93
+ # On stderr, where the engine's errors go, so the server can tell what the project's autoloads
94
+ # report after this from what the operation reported before it.
95
+ printerr(ANSWERED)
89
96
  quit()
90
97
 
91
98