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.
@@ -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}
@@ -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 = ProjectSettings.save()
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 = ProjectSettings.save()
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 ProjectSettings.save()
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 ProjectSettings.save()
182
+ return ProjectFile.save()