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.
- package/build/cli.js +508 -174
- package/build/godot/addons/gdharness_editor/serialisation.gd +29 -0
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +290 -0
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +7 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +24 -4
- package/build/godot/addons/gdharness_runtime/runtime_pad.gd +150 -0
- package/build/godot/addons/gdharness_runtime/runtime_pad.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +5 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
- package/build/godot/addons/gdharness_runtime/serialisation.gd +29 -0
- package/build/godot/operations/godot_operations.gd +7 -0
- package/build/godot/operations/import_pipeline.gd +92 -0
- package/build/godot/operations/input_actions.gd +3 -2
- package/build/godot/operations/plugins.gd +3 -2
- package/build/godot/operations/project_config.gd +17 -9
- package/build/godot/operations/project_file.gd +202 -0
- package/build/godot/operations/serialisation.gd +29 -0
- package/build/index.js +520 -190
- package/build/keeper.js +16 -9
- package/package.json +1 -1
|
@@ -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}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://c8abcitp7crgk
|
|
@@ -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
|
-
% [
|
|
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
|
-
% [
|
|
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
|
|