gdharness 1.2.25 → 1.2.27
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 +277 -50
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +294 -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_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/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/index.js +272 -48
- package/build/keeper.js +1207 -750
- package/package.json +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
$deadline = [DateTime]::UtcNow.AddMilliseconds($SettleMs)
|
|
104
|
+
$empty = [DateTime]::UtcNow.AddMilliseconds($EmptyMs)
|
|
105
|
+
# The window's corner is read on every walk: before the engine builds its tree the window is
|
|
106
|
+
# its frame, and after it the client area inside it, so a corner read once placed everything
|
|
107
|
+
# by the frame's.
|
|
108
|
+
$r = $root.Current.BoundingRectangle
|
|
109
|
+
$tree = Describe $root $r.X $r.Y 0
|
|
110
|
+
$last = Count $tree
|
|
111
|
+
# Walked again until two walks in a row agree, since the first question is what makes the
|
|
112
|
+
# engine build the tree, which arrives over the frames after it.
|
|
113
|
+
while ([DateTime]::UtcNow -lt $deadline) {
|
|
114
|
+
Start-Sleep -Milliseconds 150
|
|
115
|
+
$r = $root.Current.BoundingRectangle
|
|
116
|
+
$tree = Describe $root $r.X $r.Y 0
|
|
117
|
+
$count = Count $tree
|
|
118
|
+
if ($count -gt 1 -and $count -eq $last) { break }
|
|
119
|
+
if ($count -eq 1 -and [DateTime]::UtcNow -gt $empty) { break }
|
|
120
|
+
$last = $count
|
|
121
|
+
}
|
|
122
|
+
$windows += , $tree
|
|
123
|
+
}
|
|
124
|
+
Answer @{ windows = $windows }
|
|
125
|
+
} catch {
|
|
126
|
+
Answer @{ error = "$_" }
|
|
127
|
+
}
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
var _host: Node
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
func _init(host: Node) -> void:
|
|
134
|
+
_host = host
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## The game's windows as a screen reader is told them, each a tree of elements.
|
|
138
|
+
func get_accessibility(_params: Dictionary) -> Dictionary:
|
|
139
|
+
if DisplayServer.get_name() == "headless":
|
|
140
|
+
return _refused(
|
|
141
|
+
(
|
|
142
|
+
"A headless run has no window, so a screen reader is told nothing: start the run with"
|
|
143
|
+
+ " headless: false."
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if OS.get_name() != "Windows":
|
|
147
|
+
return _refused(
|
|
148
|
+
(
|
|
149
|
+
(
|
|
150
|
+
"The tree is read through UI Automation, the interface a Windows screen reader uses, and this"
|
|
151
|
+
+ " game runs on %s, whose screen readers ask it through another"
|
|
152
|
+
)
|
|
153
|
+
% OS.get_name()
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
var handles: Array[String] = []
|
|
157
|
+
var ids: Array[int] = []
|
|
158
|
+
for id: int in DisplayServer.get_window_list():
|
|
159
|
+
var handle: int = DisplayServer.window_get_native_handle(DisplayServer.WINDOW_HANDLE, id)
|
|
160
|
+
if handle != 0:
|
|
161
|
+
handles.append(str(handle))
|
|
162
|
+
ids.append(id)
|
|
163
|
+
|
|
164
|
+
var out: String = OS.get_temp_dir().path_join(
|
|
165
|
+
"gdharness-accessibility-%d-%d.json" % [OS.get_process_id(), Time.get_ticks_usec()]
|
|
166
|
+
)
|
|
167
|
+
var script: String = (
|
|
168
|
+
(
|
|
169
|
+
"$Handles = @(%s)\n$Out = '%s'\n$SettleMs = %d\n$EmptyMs = %d\n$MostLevels = %d\n"
|
|
170
|
+
% [", ".join(handles), out.replace("'", "''"), SETTLE_MS, EMPTY_MS, MOST_LEVELS]
|
|
171
|
+
)
|
|
172
|
+
+ WALK
|
|
173
|
+
)
|
|
174
|
+
# The system's own PowerShell by its path, so a powershell.exe earlier on PATH is not what runs.
|
|
175
|
+
var shell: String = OS.get_environment("SystemRoot").path_join(
|
|
176
|
+
"System32/WindowsPowerShell/v1.0/powershell.exe"
|
|
177
|
+
)
|
|
178
|
+
var pid: int = (
|
|
179
|
+
OS
|
|
180
|
+
. create_process(
|
|
181
|
+
shell,
|
|
182
|
+
[
|
|
183
|
+
"-NoProfile",
|
|
184
|
+
"-NonInteractive",
|
|
185
|
+
"-EncodedCommand",
|
|
186
|
+
Marshalls.raw_to_base64(script.to_utf16_buffer()),
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
if pid <= 0:
|
|
191
|
+
return _refused("PowerShell could not be started from %s to read the tree" % shell)
|
|
192
|
+
# Waited out a frame at a time rather than blocking: the client's questions are answered on the
|
|
193
|
+
# game's main thread, which a blocking wait would hold, and neither side would move.
|
|
194
|
+
var deadline: int = Time.get_ticks_msec() + SETTLE_MS + START_MS
|
|
195
|
+
while OS.is_process_running(pid):
|
|
196
|
+
if Time.get_ticks_msec() > deadline:
|
|
197
|
+
var _killed: Error = OS.kill(pid)
|
|
198
|
+
return _refused(
|
|
199
|
+
"UI Automation had not read the tree after %d ms, so it was stopped" % (SETTLE_MS + START_MS)
|
|
200
|
+
)
|
|
201
|
+
await _host.get_tree().process_frame
|
|
202
|
+
|
|
203
|
+
var text: String = FileAccess.get_file_as_string(out)
|
|
204
|
+
# Not checked: a file that is not there was never written, which the answer below says.
|
|
205
|
+
var _removed: Error = DirAccess.remove_absolute(out)
|
|
206
|
+
var read: Variant = Read.json_or_null(text)
|
|
207
|
+
if not read is Dictionary:
|
|
208
|
+
return _refused("UI Automation answered nothing readable: %s" % text.left(500))
|
|
209
|
+
var answer: Dictionary = read
|
|
210
|
+
if answer.has("error"):
|
|
211
|
+
return _refused("UI Automation could not read the game's windows: %s" % str(answer["error"]))
|
|
212
|
+
|
|
213
|
+
var windows: Array = answer.get("windows", [])
|
|
214
|
+
var elements: int = 0
|
|
215
|
+
var unnamed: int = 0
|
|
216
|
+
for at: int in windows.size():
|
|
217
|
+
var window: Dictionary = windows[at]
|
|
218
|
+
window["window_id"] = ids[at] if at < ids.size() else -1
|
|
219
|
+
elements += _count(window)
|
|
220
|
+
unnamed += _unnamed_text(window)
|
|
221
|
+
var result: Dictionary = {"windows": windows, "elements": elements}
|
|
222
|
+
if elements <= windows.size():
|
|
223
|
+
var setting: String = _setting()
|
|
224
|
+
# The command line's --accessibility is consumed by the engine and not handed on, so only
|
|
225
|
+
# the setting and a screen reader can say the tree is built; otherwise an empty window is
|
|
226
|
+
# most likely a game that builds none.
|
|
227
|
+
# 1 is a screen reader running, 0 none and -1 a platform that cannot say.
|
|
228
|
+
var read_aloud: bool = setting == "auto" and DisplayServer.accessibility_screen_reader_active() == 1
|
|
229
|
+
if setting != "always" and not read_aloud:
|
|
230
|
+
return _refused(
|
|
231
|
+
(
|
|
232
|
+
(
|
|
233
|
+
"UI Automation found nothing in the game's windows: the engine builds the tree a screen"
|
|
234
|
+
+ " reader reads only with accessibility/general/accessibility_support at Always Active,"
|
|
235
|
+
+ " or at Auto with a screen reader running when it starts, and this project's is %s."
|
|
236
|
+
+ ' Start the run with engineArgs ["--accessibility", "always"].'
|
|
237
|
+
)
|
|
238
|
+
% setting
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
result["_note"] = (
|
|
242
|
+
"The windows hold nothing a screen reader is told: Controls are in the tree, and Node2D and"
|
|
243
|
+
+ " Node3D are not unless the game creates elements for them."
|
|
244
|
+
)
|
|
245
|
+
elif unnamed > 0:
|
|
246
|
+
# Seen on a game naming its lines with accessibility_update_set_name: the text was there in
|
|
247
|
+
# the game and empty to the screen reader.
|
|
248
|
+
result["_note"] = (
|
|
249
|
+
(
|
|
250
|
+
"%d text element(s) on screen have no name, so a screen reader reads them as nothing. Static"
|
|
251
|
+
+ " text is read from its value, DisplayServer.accessibility_update_set_value, and a name"
|
|
252
|
+
+ " set on it is not read."
|
|
253
|
+
)
|
|
254
|
+
% unnamed
|
|
255
|
+
)
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
## The project's accessibility mode, as the command line's --accessibility names them.
|
|
260
|
+
static func _setting() -> String:
|
|
261
|
+
match Read.as_int(ProjectSettings.get_setting("accessibility/general/accessibility_support", 0), 0):
|
|
262
|
+
1:
|
|
263
|
+
return "always"
|
|
264
|
+
2:
|
|
265
|
+
return "disabled"
|
|
266
|
+
return "auto"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
static func _count(element: Dictionary) -> int:
|
|
270
|
+
var total: int = 1
|
|
271
|
+
var children: Array = element.get("children", [])
|
|
272
|
+
for child: Variant in children:
|
|
273
|
+
if child is Dictionary:
|
|
274
|
+
var inner: Dictionary = child
|
|
275
|
+
total += _count(inner)
|
|
276
|
+
return total
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
## Text elements with bounds and no name. A window also holds a nameless text element with no bounds
|
|
280
|
+
## that no node of the game made, in every scene tried, which is left out.
|
|
281
|
+
static func _unnamed_text(element: Dictionary) -> int:
|
|
282
|
+
var total: int = 0
|
|
283
|
+
if element.get("role") == "text" and str(element.get("name", "")).is_empty() and element.has("bounds"):
|
|
284
|
+
total += 1
|
|
285
|
+
var children: Array = element.get("children", [])
|
|
286
|
+
for child: Variant in children:
|
|
287
|
+
if child is Dictionary:
|
|
288
|
+
var inner: Dictionary = child
|
|
289
|
+
total += _unnamed_text(inner)
|
|
290
|
+
return total
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
static func _refused(message: String) -> Dictionary:
|
|
294
|
+
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,
|
|
@@ -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():
|
|
@@ -46,6 +46,9 @@ const IMPORTER_VERSIONS: Dictionary[String, int] = {"scene": 1, "animation_libra
|
|
|
46
46
|
## The VRAM compression formats a project can ask for, each behind its
|
|
47
47
|
## `rendering/textures/vram_compression/import_<format>` setting.
|
|
48
48
|
const VRAM_FORMATS: Array[String] = ["s3tc_bptc", "etc2_astc"]
|
|
49
|
+
## Options keyed by OpenType tags, by the importer that holds them. The engine keys them by the tag as
|
|
50
|
+
## an int, which a caller can also name, as "liga" for ligatures.
|
|
51
|
+
const OPENTYPE_OPTIONS: Dictionary[String, Array] = {"font_data_dynamic": ["opentype_features"]}
|
|
49
52
|
const EDITOR_DIRECTORY: String = "res://.godot/editor"
|
|
50
53
|
const EDITOR_CACHE_PREFIX: String = "filesystem_cache"
|
|
51
54
|
|
|
@@ -177,6 +180,7 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
177
180
|
return _log.failure("Failed to parse import file: " + str(err))
|
|
178
181
|
|
|
179
182
|
# Every option checked before any is written, so a refused one leaves the sidecar as it was.
|
|
183
|
+
var importer: String = str(config.get_value("remap", "importer", ""))
|
|
180
184
|
var fitted_values: Dictionary = {}
|
|
181
185
|
var refused: Array[String] = []
|
|
182
186
|
for key: Variant in options:
|
|
@@ -184,6 +188,13 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
184
188
|
var held: Variant = config.get_value("params", name, null)
|
|
185
189
|
var given: Variant = _values.deserialize_value(options[key])
|
|
186
190
|
var value: Variant = _as_option(_values.fitted(options[key], typeof(held)), held)
|
|
191
|
+
if value is Dictionary:
|
|
192
|
+
var entries: Dictionary = value
|
|
193
|
+
var keyed: Dictionary = _keyed_as_option(name, entries, held, importer)
|
|
194
|
+
if keyed.has("refused"):
|
|
195
|
+
refused.append(str(keyed["refused"]))
|
|
196
|
+
continue
|
|
197
|
+
value = keyed["value"]
|
|
187
198
|
# A number the option cannot hold whole is refused rather than truncated: 2.5 fitted to an
|
|
188
199
|
# enum option became 2, which is another setting.
|
|
189
200
|
var lossy: bool = (
|
|
@@ -227,6 +238,87 @@ static func _as_option(given: Variant, held: Variant) -> Variant:
|
|
|
227
238
|
return given
|
|
228
239
|
|
|
229
240
|
|
|
241
|
+
## [param given] with the keys and values a dictionary option holds as ints made ints again.
|
|
242
|
+
##
|
|
243
|
+
## JSON names every key as a string and every number as a float, so a font's opentype_features was
|
|
244
|
+
## written as {"1818847073": 0.0}. The importer hands that to FontFile.opentype_feature_overrides,
|
|
245
|
+
## which keys features by their tag as an int, so the call reported success and the font kept its
|
|
246
|
+
## ligatures. A key is an int where the importer keys the option by OpenType tags, or where every key
|
|
247
|
+
## the option holds already is one; a value is an int where every value held is, or for a tag.
|
|
248
|
+
## Answers the dictionary under value, or why it cannot be written under refused.
|
|
249
|
+
static func _keyed_as_option(name: String, given: Dictionary, held: Variant, importer: String) -> Dictionary:
|
|
250
|
+
var tag_options: Array = OPENTYPE_OPTIONS.get(importer, [])
|
|
251
|
+
var tagged: bool = name in tag_options
|
|
252
|
+
var numbered_keys: bool = tagged or _all_ints(held, true)
|
|
253
|
+
var whole_values: bool = tagged or _all_ints(held, false)
|
|
254
|
+
if not numbered_keys and not whole_values:
|
|
255
|
+
return {"value": given}
|
|
256
|
+
var keyed: Dictionary = {}
|
|
257
|
+
for key: Variant in given:
|
|
258
|
+
var as_key: Variant = key
|
|
259
|
+
if numbered_keys:
|
|
260
|
+
as_key = _int_key(key, tagged)
|
|
261
|
+
if as_key == null:
|
|
262
|
+
var wanted: String = (
|
|
263
|
+
'OpenType tags, by name such as "liga" or as the number TextServer.name_to_tag gives'
|
|
264
|
+
if tagged
|
|
265
|
+
else "ints"
|
|
266
|
+
)
|
|
267
|
+
return {
|
|
268
|
+
"refused": "%s is keyed by %s, and %s is not one" % [name, wanted, JSON.stringify(key)]
|
|
269
|
+
}
|
|
270
|
+
var value: Variant = given[key]
|
|
271
|
+
if whole_values and value is float:
|
|
272
|
+
var number: float = value
|
|
273
|
+
if number == floorf(number):
|
|
274
|
+
value = int(number)
|
|
275
|
+
keyed[as_key] = value
|
|
276
|
+
return {"value": keyed}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
## [param key] as an int key, or null when it is not one: a whole number, written as a number or as
|
|
280
|
+
## a string of digits, or where [param tagged], an OpenType tag's four characters.
|
|
281
|
+
static func _int_key(key: Variant, tagged: bool) -> Variant:
|
|
282
|
+
if key is int:
|
|
283
|
+
return key
|
|
284
|
+
if key is float:
|
|
285
|
+
var number: float = key
|
|
286
|
+
if number != floorf(number):
|
|
287
|
+
return null
|
|
288
|
+
return int(number)
|
|
289
|
+
if not key is String:
|
|
290
|
+
return null
|
|
291
|
+
var text: String = key
|
|
292
|
+
if text.is_valid_int():
|
|
293
|
+
return text.to_int()
|
|
294
|
+
# Exactly four printable characters, since the engine reads a longer name by its first four and
|
|
295
|
+
# "ligatures" would quietly become "liga".
|
|
296
|
+
if tagged and text.length() == 4 and _printable(text):
|
|
297
|
+
return TextServerManager.get_primary_interface().name_to_tag(text)
|
|
298
|
+
return null
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
static func _printable(text: String) -> bool:
|
|
302
|
+
for i: int in text.length():
|
|
303
|
+
var code: int = text.unicode_at(i)
|
|
304
|
+
if code < 0x20 or code > 0x7e:
|
|
305
|
+
return false
|
|
306
|
+
return true
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
## Whether [param held] is a dictionary that is not empty and whose keys, or values, are all ints.
|
|
310
|
+
static func _all_ints(held: Variant, keys: bool) -> bool:
|
|
311
|
+
if not held is Dictionary:
|
|
312
|
+
return false
|
|
313
|
+
var dictionary: Dictionary = held
|
|
314
|
+
if dictionary.is_empty():
|
|
315
|
+
return false
|
|
316
|
+
for item: Variant in dictionary.keys() if keys else dictionary.values():
|
|
317
|
+
if not item is int:
|
|
318
|
+
return false
|
|
319
|
+
return true
|
|
320
|
+
|
|
321
|
+
|
|
230
322
|
# The names of the project's export presets, in the order export_presets.cfg holds them.
|
|
231
323
|
static func _preset_names() -> Array[String]:
|
|
232
324
|
var names: Array[String] = []
|
|
@@ -2,6 +2,7 @@ extends RefCounted
|
|
|
2
2
|
|
|
3
3
|
const Read = preload("reading.gd")
|
|
4
4
|
const Log = preload("logger.gd")
|
|
5
|
+
const ProjectFile = preload("project_file.gd")
|
|
5
6
|
|
|
6
7
|
# The key names a caller may write, and what the engine calls them.
|
|
7
8
|
const KEY_NAMES: Dictionary = {
|
|
@@ -172,7 +173,7 @@ func add_input_action(params: Dictionary) -> Dictionary:
|
|
|
172
173
|
# Through ProjectSettings so the file is saved the way the editor saves it, header and
|
|
173
174
|
# every other line kept; a ConfigFile of project.godot drops the comments on the way out.
|
|
174
175
|
ProjectSettings.set_setting("input/" + action_name, action)
|
|
175
|
-
var err: Error =
|
|
176
|
+
var err: Error = ProjectFile.save()
|
|
176
177
|
if err != OK:
|
|
177
178
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
178
179
|
|
|
@@ -205,7 +206,7 @@ func remove_input_action(params: Dictionary) -> Dictionary:
|
|
|
205
206
|
)
|
|
206
207
|
)
|
|
207
208
|
ProjectSettings.set_setting(setting, null)
|
|
208
|
-
var err: Error =
|
|
209
|
+
var err: Error = ProjectFile.save()
|
|
209
210
|
if err != OK:
|
|
210
211
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
211
212
|
return {"action_name": action_name, "removed": true}
|
|
@@ -2,6 +2,7 @@ extends RefCounted
|
|
|
2
2
|
|
|
3
3
|
const Patterns = preload("patterns.gd")
|
|
4
4
|
const Log = preload("logger.gd")
|
|
5
|
+
const ProjectFile = preload("project_file.gd")
|
|
5
6
|
|
|
6
7
|
var _log: Log
|
|
7
8
|
|
|
@@ -171,11 +172,11 @@ static func _names_of(paths: Array[String]) -> Array[String]:
|
|
|
171
172
|
func _save_enabled_paths(paths: Array[String]) -> Error:
|
|
172
173
|
if paths.is_empty():
|
|
173
174
|
ProjectSettings.set_setting("editor_plugins/enabled", null)
|
|
174
|
-
return
|
|
175
|
+
return ProjectFile.save()
|
|
175
176
|
|
|
176
177
|
# Kept in an Array[String] and converted here, because appending to a packed array answers with
|
|
177
178
|
# whether it worked and a project holding return_value_discarded at error level will not compile
|
|
178
179
|
# a script that drops that answer. The conversion is what matters: an Array[String] handed to
|
|
179
180
|
# set_setting is written as Array[String]([...]), which the editor does not read as a plugin list.
|
|
180
181
|
ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(paths))
|
|
181
|
-
return
|
|
182
|
+
return ProjectFile.save()
|