gdharness 1.2.25 → 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 +82 -16
- 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_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 +82 -16
- package/build/keeper.js +4 -1
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -652,7 +652,7 @@ function engineFlags(given) {
|
|
|
652
652
|
const setBy = Object.hasOwn(SET_BY_ARGUMENT, flag) ? SET_BY_ARGUMENT[flag] : undefined;
|
|
653
653
|
return {
|
|
654
654
|
ok: false,
|
|
655
|
-
reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU
|
|
655
|
+
reason: setBy !== undefined ? `${flag} is set by this tool's own argument, ${setBy}, rather than through engineArgs.` : `${flag} is not one of the engine flags engineArgs takes, which are those that change what the engine logs, draws for debugging, checks on the GPU, which renderer draws, how it times frames or whether it builds the tree a screen reader reads, and Movie Maker's --write-movie: ${offered}. A value goes as the next entry, as in ["--fixed-fps", "60"].`
|
|
656
656
|
};
|
|
657
657
|
}
|
|
658
658
|
if (takes === "none") {
|
|
@@ -748,12 +748,27 @@ var init_launch = __esm(() => {
|
|
|
748
748
|
"--fixed-fps": "number",
|
|
749
749
|
"--max-fps": "number",
|
|
750
750
|
"--resolution": "size",
|
|
751
|
-
"--write-movie": "file"
|
|
751
|
+
"--write-movie": "file",
|
|
752
|
+
"--rendering-driver": "driver",
|
|
753
|
+
"--rendering-method": "method",
|
|
754
|
+
"--accessibility": "accessibility"
|
|
752
755
|
});
|
|
753
756
|
VALUE_KIND = {
|
|
754
757
|
number: { pattern: /^\d+(?:\.\d+)?$/, named: "a number" },
|
|
755
758
|
size: { pattern: /^\d+x\d+$/, named: "a size such as 1280x720" },
|
|
756
|
-
file: { pattern: /^[^-]/, named: "a file path" }
|
|
759
|
+
file: { pattern: /^[^-]/, named: "a file path" },
|
|
760
|
+
driver: {
|
|
761
|
+
pattern: /^(?:vulkan|d3d12|metal|opengl3|opengl3_es|opengl3_angle|dummy)$/,
|
|
762
|
+
named: "a rendering driver: vulkan, d3d12, metal, opengl3, opengl3_es, opengl3_angle or dummy"
|
|
763
|
+
},
|
|
764
|
+
method: {
|
|
765
|
+
pattern: /^(?:forward_plus|mobile|gl_compatibility)$/,
|
|
766
|
+
named: "a rendering method: forward_plus, mobile or gl_compatibility"
|
|
767
|
+
},
|
|
768
|
+
accessibility: {
|
|
769
|
+
pattern: /^(?:auto|always|disabled)$/,
|
|
770
|
+
named: "an accessibility mode: auto, always or disabled"
|
|
771
|
+
}
|
|
757
772
|
};
|
|
758
773
|
SET_BY_ARGUMENT = Object.freeze({
|
|
759
774
|
"--headless": "headless",
|
|
@@ -8365,7 +8380,7 @@ function reported2(problems) {
|
|
|
8365
8380
|
return omitted === 0 ? { messages: problems } : { messages: problems.slice(omitted), messagesOmitted: omitted };
|
|
8366
8381
|
}
|
|
8367
8382
|
function reason(log, problems) {
|
|
8368
|
-
const own = log
|
|
8383
|
+
const own = refusalsIn(log);
|
|
8369
8384
|
if (own.length > 0) {
|
|
8370
8385
|
return own.map((entry) => entry.text.slice(OWN_ERROR.length)).join("; ");
|
|
8371
8386
|
}
|
|
@@ -8377,6 +8392,9 @@ function reason(log, problems) {
|
|
|
8377
8392
|
const printed = log.everything().filter((entry) => entry.source === "stdout");
|
|
8378
8393
|
return printed.at(-1)?.text ?? "no output at all";
|
|
8379
8394
|
}
|
|
8395
|
+
function refusalsIn(log) {
|
|
8396
|
+
return log.everything().filter((entry) => entry.source === "stderr" && entry.severity === "info" && entry.text.startsWith(OWN_ERROR));
|
|
8397
|
+
}
|
|
8380
8398
|
function scriptFault(problems, script) {
|
|
8381
8399
|
const spelled = (path) => `(${path.replaceAll("\\", "/").toLowerCase()}/`;
|
|
8382
8400
|
const directories = [...new Set([spelled(dirname4(script)), spelled(resolvedOr(dirname4(script)))])];
|
|
@@ -8496,6 +8514,15 @@ async function runOperation(engine, operation, params, projectPath) {
|
|
|
8496
8514
|
};
|
|
8497
8515
|
}
|
|
8498
8516
|
if (answer.kind === "none") {
|
|
8517
|
+
const refusals = refusalsIn(ran.log);
|
|
8518
|
+
if (refusals.length > 0) {
|
|
8519
|
+
const said = new Set(refusals.map((entry) => entry.index));
|
|
8520
|
+
return {
|
|
8521
|
+
ok: false,
|
|
8522
|
+
message: refusals.map((entry) => entry.text.slice(OWN_ERROR.length)).join("; "),
|
|
8523
|
+
...reported2(problems.filter((entry) => !said.has(entry.index)))
|
|
8524
|
+
};
|
|
8525
|
+
}
|
|
8499
8526
|
const ending = ran.exitCode === 0 && ran.failure === null ? "produced no result" : `failed (${howItEnded(ran)})`;
|
|
8500
8527
|
return {
|
|
8501
8528
|
ok: false,
|
|
@@ -11127,7 +11154,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
11127
11154
|
{
|
|
11128
11155
|
name: "project_settings",
|
|
11129
11156
|
title: "Project settings",
|
|
11130
|
-
description: "Reads or writes project.godot: settings, autoloads, the main scene, input actions, plugins and audio buses. A write with an editor open on the project is handed to the editor, which takes the settings the write changed or names from the file (named under editorAdopted), including a setting the file already held, since the editor can hold another value after the file was put back, or reloads the bus layout, so it answers the new values and does not save the old ones back over them. editorNote is an editor that could not take the write, one on an addon from before this, which still holds the old values until editor_launch restart. An enabled plugin's code still loads on the editor's next start. set answers old_value and new_value, with was_new true when project.godot did not set the setting before, and was_default true when what it held was the engine's own default, which old_value then is. get answers exists, true for any setting the engine knows, and at_default, true when the value is the engine's own default and so has no line in project.godot. Each setting under prefix carries at_default too, so a family read separates what the project sets from the engine's defaults; it is absent for a setting the engine registers no default for, such as an input action the project added, which only the project can have set.",
|
|
11157
|
+
description: "Reads or writes project.godot: settings, autoloads, the main scene, input actions, plugins and audio buses. A write with an editor open on the project is handed to the editor, which takes the settings the write changed or names from the file (named under editorAdopted), including a setting the file already held, since the editor can hold another value after the file was put back, or reloads the bus layout, so it answers the new values and does not save the old ones back over them. editorNote is an editor that could not take the write, one on an addon from before this, which still holds the old values until editor_launch restart. An enabled plugin's code still loads on the editor's next start. set answers old_value and new_value, with was_new true when project.godot did not set the setting before, and was_default true when what it held was the engine's own default, which old_value then is. A set of the value the setting already holds, in the same type, writes nothing and answers unchanged true and saved false. Every write keeps the sections and keys of project.godot in the order the file had them, so a change touches only its own lines. get answers exists, true for any setting the engine knows, and at_default, true when the value is the engine's own default and so has no line in project.godot. Each setting under prefix carries at_default too, so a family read separates what the project sets from the engine's defaults; it is absent for a setting the engine registers no default for, such as an input action the project added, which only the project can have set.",
|
|
11131
11158
|
parameters: {
|
|
11132
11159
|
projectPath: PROJECT_PATH,
|
|
11133
11160
|
setting: {
|
|
@@ -11312,7 +11339,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
11312
11339
|
},
|
|
11313
11340
|
options: {
|
|
11314
11341
|
type: "object",
|
|
11315
|
-
description:
|
|
11342
|
+
description: `set_options: import options keyed as the .import file spells them, {"compress/mode": 1}. A dictionary option whose keys are numbers, a font's opentype_features or any option already holding number keys, takes them written as strings of digits, and opentype_features also takes an OpenType tag by its four-letter name: {"opentype_features": {"liga": 0}} turns ligatures off. Both are written as the numbers the engine reads.`,
|
|
11316
11343
|
additionalProperties: true
|
|
11317
11344
|
},
|
|
11318
11345
|
reimport: {
|
|
@@ -11400,7 +11427,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
11400
11427
|
engineArgs: {
|
|
11401
11428
|
type: "array",
|
|
11402
11429
|
items: { type: "string" },
|
|
11403
|
-
description: `The engine's own flags for the run, such as ["--verbose"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU
|
|
11430
|
+
description: `The engine's own flags for the run, such as ["--verbose"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU, which renderer draws, how it times frames or whether it builds the tree a screen reader reads: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused. With --verbose, engineEntries holds the ordinary lines the run printed as well, which is where the engine names the instances leaked and the resources still in use at exit.`
|
|
11404
11431
|
},
|
|
11405
11432
|
failFast: {
|
|
11406
11433
|
type: "boolean",
|
|
@@ -11877,7 +11904,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
11877
11904
|
type: "array",
|
|
11878
11905
|
items: { type: "string" },
|
|
11879
11906
|
ops: ["start", "check"],
|
|
11880
|
-
description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU
|
|
11907
|
+
description: `The engine's own flags, before the separator, such as ["--verbose"] or ["--fixed-fps", "60"], a value going as the next entry. Only those that change what the engine logs, draws for debugging, checks on the GPU, which renderer draws, how it times frames or whether it builds the tree a screen reader reads, and Movie Maker's --write-movie: ${Object.keys(ENGINE_FLAGS).join(", ")}. Any other is refused, since the engine's command line can also run scripts, open other projects and write files. --rendering-driver takes vulkan, d3d12, metal, opengl3, opengl3_es, opengl3_angle or dummy, and --rendering-method forward_plus, mobile or gl_compatibility, so the same scene can be measured on another driver and a project's OpenGL fallback exercised on a machine that has Vulkan; they choose what draws a run with a window. --accessibility always has the game build the tree a screen reader reads, as the project's accessibility/general/accessibility_support at Always Active does, so runtime_inspect accessibility can read it; otherwise the engine builds it only when a screen reader is running. --write-movie takes an absolute path outside the project, in a directory that exists, ending in .avi (one file, video and sound) or .png (one numbered PNG per frame and a .wav beside them), where nothing it would write is there already; it needs a start with headless: false, since the engine crashes writing a movie headless, and it records at the project's editor/movie_writer/fps unless --fixed-fps says otherwise. A run with any is started by this server, as one with args is. A check given --verbose answers with everything printed rather than only errors and warnings, since what verbose adds, the instances leaked at exit among it, is printed as ordinary lines.`
|
|
11881
11908
|
},
|
|
11882
11909
|
headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
|
|
11883
11910
|
visible: {
|
|
@@ -12117,7 +12144,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
12117
12144
|
{
|
|
12118
12145
|
name: "runtime_inspect",
|
|
12119
12146
|
title: "Inspect the running game",
|
|
12120
|
-
description: "Questions about the running game: what is written on the screen, the scene tree, the nodes matching a query, where one node is on screen, what one property reads,
|
|
12147
|
+
description: "Questions about the running game: what is written on the screen, the scene tree, the nodes matching a query, where one node is on screen, what one property reads, the performance metrics, or what it tells a screen reader. Needs the game running with the runtime addon.",
|
|
12121
12148
|
parameters: {
|
|
12122
12149
|
projectPath: RUNNING_PROJECT_PATH,
|
|
12123
12150
|
pid: RUNNING_PID,
|
|
@@ -12186,7 +12213,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
12186
12213
|
timeoutMs: {
|
|
12187
12214
|
type: "integer",
|
|
12188
12215
|
minimum: 1,
|
|
12189
|
-
description: "How long to wait for the answer before answering pending with a requestId, which runtime_invoke op result collects the reply by. Default 10000, or GDHARNESS_RUNTIME_TIMEOUT_MS."
|
|
12216
|
+
description: "How long to wait for the answer before answering pending with a requestId, which runtime_invoke op result collects the reply by. Default 10000, or GDHARNESS_RUNTIME_TIMEOUT_MS; accessibility defaults to 30000 or that, whichever is longer."
|
|
12190
12217
|
}
|
|
12191
12218
|
},
|
|
12192
12219
|
requires: [],
|
|
@@ -12214,7 +12241,12 @@ var init_tool_definitions = __esm(() => {
|
|
|
12214
12241
|
},
|
|
12215
12242
|
metrics: {
|
|
12216
12243
|
effect: "read",
|
|
12217
|
-
summary: "frame time, memory, draw calls and the rest. frame_time is the mean wall time of a frame over the last frames_timed frames (up to 120, timed by the game itself, physics and the wait for the display included), frame_time_max the slowest of them, both in seconds and null before a frame has passed; vsync says whether a frame waits for the display, and max_fps is the game's frame-rate cap, 0 for none. fps, process_time_max and physics_time are the engine's monitors, which it sets once a second: the frames counted in the last second, and the slowest process and physics step in it, so they trail a change by up to a second and the last two are a worst case rather than the frame. The memory, object and render counts are read as asked",
|
|
12244
|
+
summary: "frame time, memory, draw calls and the rest. frame_time is the mean wall time of a frame over the last frames_timed frames (up to 120, timed by the game itself, physics and the wait for the display included), frame_time_max the slowest of them, both in seconds and null before a frame has passed; vsync says whether a frame waits for the display, and max_fps is the game's frame-rate cap, 0 for none. fps, process_time_max and physics_time are the engine's monitors, which it sets once a second: the frames counted in the last second, and the slowest process and physics step in it, so they trail a change by up to a second and the last two are a worst case rather than the frame. The memory, object and render counts are read as asked. memory_static is what the engine allocated on the CPU side, in bytes; render_video_mem_used, render_texture_mem_used and render_buffer_mem_used are what the renderer has on the GPU, in bytes, which memory_static does not count",
|
|
12245
|
+
requires: []
|
|
12246
|
+
},
|
|
12247
|
+
accessibility: {
|
|
12248
|
+
effect: "read",
|
|
12249
|
+
summary: `what the game tells a screen reader, read through UI Automation as a Windows screen reader reads it, under windows: one tree per window, each element with its role as UI Automation names it (text for static text, button, check_box, group for a control holding others), its name, its value where it has one, its bounds in the window's pixels, the actions a screen reader is offered (click, focus, toggle, set_value, select, expand_collapse, scroll_into_view), and focused on the one holding the focus. The name is what is read aloud, so an empty one is read as nothing; static text takes it from DisplayServer.accessibility_update_set_value, not from a name set on it, and the answer says under _note how many text elements on screen have none. With no control focused, the engine reports the first node under the root as focused, which in a run with the runtime is GdharnessRuntime. Needs a windowed run on Windows started with engineArgs ["--accessibility", "always"] or with the project's accessibility/general/accessibility_support at Always Active, since the engine builds the tree only when asked; reading it takes a second or two, starting PowerShell inside the game so it reaches the game's own desktop`,
|
|
12218
12250
|
requires: []
|
|
12219
12251
|
}
|
|
12220
12252
|
},
|
|
@@ -12344,7 +12376,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
12344
12376
|
name: "runtime_input",
|
|
12345
12377
|
title: "Send input to the game",
|
|
12346
12378
|
description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: every pointer answer there carries pointer_on_hidden_desktop, and the first of the run pointer_note saying why it matters: get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
|
|
12347
|
-
answer: 'click: what was under the pointer, and what became of the control: in_tree, removed or freed. Named by says, found says which match it was, and found.pressedOn says "the words" for words in a RichTextLabel pressed where they are drawn, or "the middle of the label" with a note saying why they could not be placed; on a rich label link_pressed says whether the press reached a link, with link_meta giving the meta the label reports clicked, an empty one included. A control scrolled into view first is scrolled each container out from it a frame after the one inside, and scrolled_into_view says whether a scroll value changed. A button in toggle mode, a CheckBox or CheckButton included, answers pressed with before and after, its button_pressed either side of the click: the same value twice is a click the game undid, and after is null when the click freed it. A refusal for a pointer turned off names where mouse_behavior_recursive was set. On a 3D node landed says the interface did not swallow the press: no control under the pointer stopped it, a control letting the pointer pass included, and every viewport on the way out from the room had the pointer over what shows the room. choose: what was chosen, read before the press, which is heard rather than assumed; what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick); and what the button in front of the menu shows now when it is still there. A menu is opened rather than clicked because its items are drawn rather than built, and the item is looked for as the menu stands once open, since a game can fill a menu as it opens. action, key and
|
|
12379
|
+
answer: 'click: what was under the pointer, and what became of the control: in_tree, removed or freed. Named by says, found says which match it was, and found.pressedOn says "the words" for words in a RichTextLabel pressed where they are drawn, or "the middle of the label" with a note saying why they could not be placed; on a rich label link_pressed says whether the press reached a link, with link_meta giving the meta the label reports clicked, an empty one included. A control scrolled into view first is scrolled each container out from it a frame after the one inside, and scrolled_into_view says whether a scroll value changed. A button in toggle mode, a CheckBox or CheckButton included, answers pressed with before and after, its button_pressed either side of the click: the same value twice is a click the game undid, and after is null when the click freed it. A refusal for a pointer turned off names where mouse_behavior_recursive was set. On a 3D node landed says the interface did not swallow the press: no control under the pointer stopped it, a control letting the pointer pass included, and every viewport on the way out from the room had the pointer over what shows the room. choose: what was chosen, read before the press, which is heard rather than assumed; what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick); and what the button in front of the menu shows now when it is still there. A menu is opened rather than clicked because its items are drawn rather than built, and the item is looked for as the menu stands once open, since a game can fill a menu as it opens. action, key, mouse_click and joypad_button: the event as it was sent, with whole saying whether the press was let go of again, for a key the keycode, physical_keycode and unicode it carried, and for a controller button its number, its name and the device. mouse_motion: the position and the relative movement sent. joypad_motion: the axis by number and name, the value and the device. None of these is aimed at anything, so none says what the event reached: click on a nodePath or says answers what was under the pointer.',
|
|
12348
12380
|
parameters: {
|
|
12349
12381
|
projectPath: RUNNING_PROJECT_PATH,
|
|
12350
12382
|
pid: RUNNING_PID,
|
|
@@ -12365,8 +12397,8 @@ var init_tool_definitions = __esm(() => {
|
|
|
12365
12397
|
},
|
|
12366
12398
|
pressed: {
|
|
12367
12399
|
type: "boolean",
|
|
12368
|
-
ops: ["action", "key", "mouse_click"],
|
|
12369
|
-
description: "action, key, mouse_click: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held. A wheel button held down is still a whole step, since a wheel is never held."
|
|
12400
|
+
ops: ["action", "key", "mouse_click", "joypad_button"],
|
|
12401
|
+
description: "action, key, mouse_click, joypad_button: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held. A wheel button held down is still a whole step, since a wheel is never held."
|
|
12370
12402
|
},
|
|
12371
12403
|
strength: {
|
|
12372
12404
|
type: "number",
|
|
@@ -12411,8 +12443,26 @@ var init_tool_definitions = __esm(() => {
|
|
|
12411
12443
|
},
|
|
12412
12444
|
button: {
|
|
12413
12445
|
type: ["string", "number"],
|
|
12414
|
-
ops: ["click", "mouse_click"],
|
|
12415
|
-
description: "click, mouse_click: left, right, middle, wheel_up or wheel_down, or a button number. Default left."
|
|
12446
|
+
ops: ["click", "mouse_click", "joypad_button"],
|
|
12447
|
+
description: "click, mouse_click: left, right, middle, wheel_up or wheel_down, or a button number. Default left. joypad_button: the controller button, by name (a, b, x, y, back, guide, start, left_stick, right_stick, left_shoulder, right_shoulder, dpad_up, dpad_down, dpad_left, dpad_right, misc1, paddle1 to paddle4, touchpad) or by its JoyButton number; a, b, x and y are the bottom, right, left and top face buttons, as Godot names them, whatever the controller prints on them."
|
|
12448
|
+
},
|
|
12449
|
+
axis: {
|
|
12450
|
+
type: ["string", "number"],
|
|
12451
|
+
ops: ["joypad_motion"],
|
|
12452
|
+
description: "joypad_motion: the stick or trigger axis, by name (left_x, left_y, right_x, right_y, trigger_left, trigger_right) or by its JoyAxis number."
|
|
12453
|
+
},
|
|
12454
|
+
value: {
|
|
12455
|
+
type: "number",
|
|
12456
|
+
minimum: -1,
|
|
12457
|
+
maximum: 1,
|
|
12458
|
+
ops: ["joypad_motion"],
|
|
12459
|
+
description: "joypad_motion: where the axis is, -1 to 1: down and right are positive on a stick, and a trigger runs 0 to 1. The axis stays there until another motion moves it, as a stick held over does; send 0 to let it go back."
|
|
12460
|
+
},
|
|
12461
|
+
device: {
|
|
12462
|
+
type: "number",
|
|
12463
|
+
minimum: 0,
|
|
12464
|
+
ops: ["joypad_button", "joypad_motion"],
|
|
12465
|
+
description: "joypad_button, joypad_motion: which controller, counting from 0. Default 0."
|
|
12416
12466
|
},
|
|
12417
12467
|
doubleClick: {
|
|
12418
12468
|
type: "boolean",
|
|
@@ -12462,6 +12512,16 @@ var init_tool_definitions = __esm(() => {
|
|
|
12462
12512
|
effect: "additive",
|
|
12463
12513
|
summary: "move the mouse to a position, carrying the distance from where the last injected event put it unless relativeX and relativeY say otherwise, and the buttons held, so a control that drags moves under a run of these between a held mouse_click and its release",
|
|
12464
12514
|
requires: ["x", "y"]
|
|
12515
|
+
},
|
|
12516
|
+
joypad_button: {
|
|
12517
|
+
effect: "additive",
|
|
12518
|
+
summary: "press a controller button, or hold it, as an InputEventJoypadButton, so a game reading the pad in _input or _unhandled_input gets what a controller sends; an action bound to the button fires with it, as it would for a real one. Pressed and released a frame apart unless pressed says otherwise",
|
|
12519
|
+
requires: ["button"]
|
|
12520
|
+
},
|
|
12521
|
+
joypad_motion: {
|
|
12522
|
+
effect: "additive",
|
|
12523
|
+
summary: "move a stick or trigger axis to a value, as an InputEventJoypadMotion, which is what a game reading the sticks gets; the axis stays there until another motion moves it",
|
|
12524
|
+
requires: ["axis", "value"]
|
|
12465
12525
|
}
|
|
12466
12526
|
}
|
|
12467
12527
|
},
|
|
@@ -45267,6 +45327,12 @@ class GodotServer {
|
|
|
45267
45327
|
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
45268
45328
|
property: readNonEmptyString(args, "property") ?? ""
|
|
45269
45329
|
}, { tool: "runtime_inspect", op, patience });
|
|
45330
|
+
case "accessibility":
|
|
45331
|
+
return await this.handleRuntimeCommand("get_accessibility", whichGame(args), {
|
|
45332
|
+
tool: "runtime_inspect",
|
|
45333
|
+
op,
|
|
45334
|
+
patience: readPositiveNumber(args, "timeoutMs") ?? Math.max(30000, this.runtimeTimeoutMs())
|
|
45335
|
+
});
|
|
45270
45336
|
default:
|
|
45271
45337
|
return await this.handleRuntimeCommand("get_metrics", { ...whichGame(args), metrics: readArray(args, "metrics") ?? [] }, { tool: "runtime_inspect", op, patience });
|
|
45272
45338
|
}
|
|
@@ -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,
|