gdharness 0.6.2 → 0.6.4
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 +131 -30
- package/build/godot/addons/gdharness_editor/bridge_client.gd +68 -1
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +52 -2
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +152 -0
- package/build/index.js +126 -30
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -157,8 +157,8 @@ function runArguments(options) {
|
|
|
157
157
|
}
|
|
158
158
|
return args;
|
|
159
159
|
}
|
|
160
|
-
function editorArguments(projectPath) {
|
|
161
|
-
return ["-e", "--path", projectPath];
|
|
160
|
+
function editorArguments(projectPath, ports) {
|
|
161
|
+
return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
|
|
162
162
|
}
|
|
163
163
|
function userDataIn(home, variables = process.env) {
|
|
164
164
|
const moved = ["APPDATA", "XDG_DATA_HOME"];
|
|
@@ -15834,12 +15834,12 @@ var init_tool_definitions = __esm(() => {
|
|
|
15834
15834
|
},
|
|
15835
15835
|
{
|
|
15836
15836
|
name: "runtime_input",
|
|
15837
|
-
description: "Input to the running game: a whole click on a Control or a 3D node named by path, 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.",
|
|
15837
|
+
description: "Input to the running game: a whole click on a Control or a 3D node named by path, 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.",
|
|
15838
15838
|
parameters: {
|
|
15839
15839
|
projectPath: RUNNING_PROJECT_PATH,
|
|
15840
15840
|
nodePath: {
|
|
15841
15841
|
type: "string",
|
|
15842
|
-
description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn."
|
|
15842
|
+
description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
|
|
15843
15843
|
},
|
|
15844
15844
|
action: { type: "string", description: "action: the InputMap action name." },
|
|
15845
15845
|
pressed: { type: "boolean", description: "Press or release. Default true." },
|
|
@@ -15847,7 +15847,11 @@ var init_tool_definitions = __esm(() => {
|
|
|
15847
15847
|
keycode: { type: "string", description: 'key: the key name, such as "Space" or "A".' },
|
|
15848
15848
|
text: {
|
|
15849
15849
|
type: "string",
|
|
15850
|
-
description: "text: what to type. A newline is Enter and a tab is Tab."
|
|
15850
|
+
description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
|
|
15851
|
+
},
|
|
15852
|
+
index: {
|
|
15853
|
+
type: "number",
|
|
15854
|
+
description: "choose: the item to take, by where it is in the list, when text will not do."
|
|
15851
15855
|
},
|
|
15852
15856
|
shift: { type: "boolean" },
|
|
15853
15857
|
ctrl: { type: "boolean" },
|
|
@@ -15869,6 +15873,10 @@ var init_tool_definitions = __esm(() => {
|
|
|
15869
15873
|
summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
|
|
15870
15874
|
requires: ["nodePath"]
|
|
15871
15875
|
},
|
|
15876
|
+
choose: {
|
|
15877
|
+
summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
|
|
15878
|
+
requires: ["nodePath"]
|
|
15879
|
+
},
|
|
15872
15880
|
action: { summary: "press or release an action", requires: ["action"] },
|
|
15873
15881
|
key: { summary: "press or release a key", requires: ["keycode"] },
|
|
15874
15882
|
text: {
|
|
@@ -30783,10 +30791,11 @@ var init_framing = __esm(() => {
|
|
|
30783
30791
|
});
|
|
30784
30792
|
|
|
30785
30793
|
// src/ports.ts
|
|
30786
|
-
|
|
30794
|
+
import { createServer } from "node:net";
|
|
30795
|
+
function portFromEnvOrNull(variable) {
|
|
30787
30796
|
const raw = process.env[variable]?.trim();
|
|
30788
30797
|
if (!raw) {
|
|
30789
|
-
return
|
|
30798
|
+
return null;
|
|
30790
30799
|
}
|
|
30791
30800
|
const parsed = Number.parseInt(raw, 10);
|
|
30792
30801
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
|
|
@@ -30794,6 +30803,36 @@ function portFromEnv(variable, fallback) {
|
|
|
30794
30803
|
}
|
|
30795
30804
|
return parsed;
|
|
30796
30805
|
}
|
|
30806
|
+
function portFromEnv(variable, fallback) {
|
|
30807
|
+
return portFromEnvOrNull(variable) ?? fallback;
|
|
30808
|
+
}
|
|
30809
|
+
async function freePort(preferred, host = "127.0.0.1") {
|
|
30810
|
+
const asked = await bindable(preferred, host);
|
|
30811
|
+
if (asked !== null) {
|
|
30812
|
+
return asked;
|
|
30813
|
+
}
|
|
30814
|
+
const spare = await bindable(0, host);
|
|
30815
|
+
if (spare === null) {
|
|
30816
|
+
throw new Refusal2(`No port could be bound on ${host}.`);
|
|
30817
|
+
}
|
|
30818
|
+
return spare;
|
|
30819
|
+
}
|
|
30820
|
+
function bindable(port, host) {
|
|
30821
|
+
return new Promise((resolve) => {
|
|
30822
|
+
const probe = createServer();
|
|
30823
|
+
probe.once("error", () => {
|
|
30824
|
+
resolve(null);
|
|
30825
|
+
});
|
|
30826
|
+
probe.once("listening", () => {
|
|
30827
|
+
const bound = probe.address();
|
|
30828
|
+
const taken = typeof bound === "object" && bound !== null ? bound.port : port;
|
|
30829
|
+
probe.close(() => {
|
|
30830
|
+
resolve(taken);
|
|
30831
|
+
});
|
|
30832
|
+
});
|
|
30833
|
+
probe.listen(port, host);
|
|
30834
|
+
});
|
|
30835
|
+
}
|
|
30797
30836
|
var init_ports = __esm(() => {
|
|
30798
30837
|
init_errors();
|
|
30799
30838
|
});
|
|
@@ -34181,6 +34220,9 @@ function resolveDefaultBridgeHost() {
|
|
|
34181
34220
|
const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
|
|
34182
34221
|
return host === undefined || host === "" ? DEFAULT_HOST : host;
|
|
34183
34222
|
}
|
|
34223
|
+
function servedPort(said) {
|
|
34224
|
+
return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
|
|
34225
|
+
}
|
|
34184
34226
|
function getDefaultBridge(mayMove = false) {
|
|
34185
34227
|
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
|
|
34186
34228
|
return defaultBridge;
|
|
@@ -34321,6 +34363,9 @@ var init_godot_bridge = __esm(() => {
|
|
|
34321
34363
|
lastPongAt: this.connectionInfo?.lastPongAt,
|
|
34322
34364
|
addonVersion: this.connectionInfo?.addonVersion,
|
|
34323
34365
|
editorPid: this.connectionInfo?.editorPid,
|
|
34366
|
+
lspPort: this.connectionInfo?.lspPort,
|
|
34367
|
+
dapPort: this.connectionInfo?.dapPort,
|
|
34368
|
+
debugPort: this.connectionInfo?.debugPort,
|
|
34324
34369
|
pendingRequests: this.pendingRequests.size,
|
|
34325
34370
|
queuedResources: this.resourceQueues.size
|
|
34326
34371
|
};
|
|
@@ -34454,6 +34499,9 @@ var init_godot_bridge = __esm(() => {
|
|
|
34454
34499
|
this.connectionInfo.projectPath = message.project_path;
|
|
34455
34500
|
this.connectionInfo.addonVersion = message.addon_version ?? "";
|
|
34456
34501
|
this.connectionInfo.editorPid = message.editor_pid;
|
|
34502
|
+
this.connectionInfo.lspPort = servedPort(message.lsp_port);
|
|
34503
|
+
this.connectionInfo.dapPort = servedPort(message.dap_port);
|
|
34504
|
+
this.connectionInfo.debugPort = servedPort(message.debug_port);
|
|
34457
34505
|
this.log("info", `Godot ready: ${message.project_path}`);
|
|
34458
34506
|
this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
|
|
34459
34507
|
}
|
|
@@ -35360,10 +35408,10 @@ function asToolResponse(payload) {
|
|
|
35360
35408
|
]
|
|
35361
35409
|
};
|
|
35362
35410
|
}
|
|
35363
|
-
function normalizeLSPError(error) {
|
|
35411
|
+
function normalizeLSPError(error, port) {
|
|
35364
35412
|
if (error instanceof Error) {
|
|
35365
35413
|
if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
|
|
35366
|
-
return `Godot LSP is unavailable on port ${
|
|
35414
|
+
return `Godot LSP is unavailable on port ${port}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
|
|
35367
35415
|
}
|
|
35368
35416
|
return error.message;
|
|
35369
35417
|
}
|
|
@@ -35441,7 +35489,7 @@ async function handleLSPTool(client, toolName, args) {
|
|
|
35441
35489
|
}
|
|
35442
35490
|
} catch (error) {
|
|
35443
35491
|
return asToolResponse({
|
|
35444
|
-
error: normalizeLSPError(error)
|
|
35492
|
+
error: normalizeLSPError(error, client.port)
|
|
35445
35493
|
});
|
|
35446
35494
|
}
|
|
35447
35495
|
}
|
|
@@ -36307,12 +36355,23 @@ class GodotServer {
|
|
|
36307
36355
|
case "runtime_capture":
|
|
36308
36356
|
return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
|
|
36309
36357
|
case "runtime_input":
|
|
36310
|
-
|
|
36311
|
-
|
|
36312
|
-
|
|
36313
|
-
|
|
36314
|
-
|
|
36315
|
-
|
|
36358
|
+
if (op === "click") {
|
|
36359
|
+
return await this.handleRuntimeCommand("click", {
|
|
36360
|
+
projectPath: args["projectPath"],
|
|
36361
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
36362
|
+
button: readString(args, "button") ?? "left",
|
|
36363
|
+
double: readBoolean(args, "doubleClick") ?? false
|
|
36364
|
+
});
|
|
36365
|
+
}
|
|
36366
|
+
if (op === "choose") {
|
|
36367
|
+
return await this.handleRuntimeCommand("choose", {
|
|
36368
|
+
projectPath: args["projectPath"],
|
|
36369
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
36370
|
+
text: readString(args, "text") ?? "",
|
|
36371
|
+
...args["index"] === undefined ? {} : { index: args["index"] }
|
|
36372
|
+
});
|
|
36373
|
+
}
|
|
36374
|
+
return await this.handleRuntimeCommand(`inject_${op}`, args);
|
|
36316
36375
|
case "runtime_wait":
|
|
36317
36376
|
return await this.handleRuntimeWait(op, args);
|
|
36318
36377
|
case "debug_breakpoint": {
|
|
@@ -36393,7 +36452,7 @@ class GodotServer {
|
|
|
36393
36452
|
return {
|
|
36394
36453
|
ok: false,
|
|
36395
36454
|
response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
|
|
36396
|
-
|
|
36455
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
36397
36456
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
36398
36457
|
])
|
|
36399
36458
|
};
|
|
@@ -36760,7 +36819,8 @@ class GodotServer {
|
|
|
36760
36819
|
if (payload["error"] !== undefined) {
|
|
36761
36820
|
const reason = payload["error"];
|
|
36762
36821
|
return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
|
|
36763
|
-
|
|
36822
|
+
`Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
|
|
36823
|
+
"GDHARNESS_LSP_PORT points this server at another one"
|
|
36764
36824
|
]);
|
|
36765
36825
|
}
|
|
36766
36826
|
const diagnostics = readArray(payload, "diagnostics") ?? [];
|
|
@@ -36777,16 +36837,33 @@ class GodotServer {
|
|
|
36777
36837
|
});
|
|
36778
36838
|
}
|
|
36779
36839
|
async handleLSP(toolName, args) {
|
|
36780
|
-
this.
|
|
36840
|
+
const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
|
|
36841
|
+
if (this.lspClient !== null && this.lspClient.port !== port) {
|
|
36842
|
+
await this.lspClient.disconnect();
|
|
36843
|
+
this.lspClient = null;
|
|
36844
|
+
}
|
|
36845
|
+
this.lspClient ??= new GodotLSPClient(port);
|
|
36781
36846
|
return handleLSPTool(this.lspClient, toolName, args);
|
|
36782
36847
|
}
|
|
36783
36848
|
async handleDAP(toolName, args) {
|
|
36784
36849
|
return handleDAPTool(this.dap(), toolName, args);
|
|
36785
36850
|
}
|
|
36786
36851
|
dap() {
|
|
36787
|
-
this.
|
|
36852
|
+
const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
|
|
36853
|
+
if (this.dapClient !== null && this.dapClient.port !== port) {
|
|
36854
|
+
this.dapClient = null;
|
|
36855
|
+
}
|
|
36856
|
+
this.dapClient ??= new GodotDAPClient(port);
|
|
36788
36857
|
return this.dapClient;
|
|
36789
36858
|
}
|
|
36859
|
+
editorServes(field, variable, fallback) {
|
|
36860
|
+
const named = portFromEnvOrNull(variable);
|
|
36861
|
+
if (named !== null) {
|
|
36862
|
+
return named;
|
|
36863
|
+
}
|
|
36864
|
+
const status = this.godotBridge.getStatus();
|
|
36865
|
+
return (status.connected ? status[field] : undefined) ?? fallback;
|
|
36866
|
+
}
|
|
36790
36867
|
getEditorStatusPayload() {
|
|
36791
36868
|
const status = this.godotBridge.getStatus();
|
|
36792
36869
|
const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
|
|
@@ -36814,7 +36891,8 @@ class GodotServer {
|
|
|
36814
36891
|
const answer = await this.godotBridge.invokeTool("playing_status", {});
|
|
36815
36892
|
return {
|
|
36816
36893
|
playing: readBoolean(asParams(answer), "playing") ?? false,
|
|
36817
|
-
scene: readString(asParams(answer), "scenePath") ?? ""
|
|
36894
|
+
scene: readString(asParams(answer), "scenePath") ?? "",
|
|
36895
|
+
debugPort: readNumber(asParams(answer), "debugPort")
|
|
36818
36896
|
};
|
|
36819
36897
|
} catch {
|
|
36820
36898
|
return null;
|
|
@@ -36832,15 +36910,19 @@ class GodotServer {
|
|
|
36832
36910
|
problem: reply.ok ? null : reply.message
|
|
36833
36911
|
};
|
|
36834
36912
|
}));
|
|
36913
|
+
const playing = await this.editorPlayingState();
|
|
36835
36914
|
return this.jsonTextResponse({
|
|
36836
|
-
editor:
|
|
36915
|
+
editor: {
|
|
36916
|
+
...this.getEditorStatusPayload(),
|
|
36917
|
+
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
|
|
36918
|
+
},
|
|
36837
36919
|
godot: {
|
|
36838
36920
|
path: godotPath,
|
|
36839
36921
|
version: godotPath === null ? null : await this.godotVersion(godotPath)
|
|
36840
36922
|
},
|
|
36841
36923
|
game: {
|
|
36842
36924
|
processActive: this.activeProcess !== null,
|
|
36843
|
-
playingInEditor:
|
|
36925
|
+
playingInEditor: playing,
|
|
36844
36926
|
runtimeConnected: games.some((game) => game.reachable),
|
|
36845
36927
|
runtimes: games
|
|
36846
36928
|
}
|
|
@@ -36899,10 +36981,10 @@ class GodotServer {
|
|
|
36899
36981
|
return project.response;
|
|
36900
36982
|
}
|
|
36901
36983
|
if (this.godotBridge.isConnected()) {
|
|
36902
|
-
return this.createErrorResponse("An editor is already connected to this server
|
|
36984
|
+
return this.createErrorResponse("An editor is already connected to this server.", [
|
|
36903
36985
|
"editor_status says which editor is answering, and for which project",
|
|
36904
36986
|
"editor_launch restart replaces the connected editor rather than joining it",
|
|
36905
|
-
"
|
|
36987
|
+
"Another project wants its own gdharness server, which is its own harness session"
|
|
36906
36988
|
]);
|
|
36907
36989
|
}
|
|
36908
36990
|
const engine = await this.engine();
|
|
@@ -36910,10 +36992,16 @@ class GodotServer {
|
|
|
36910
36992
|
return engine.response;
|
|
36911
36993
|
}
|
|
36912
36994
|
this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
|
|
36913
|
-
const
|
|
36995
|
+
const ports = await this.portsForAnEditor();
|
|
36996
|
+
const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
|
|
36914
36997
|
stdio: "ignore",
|
|
36915
36998
|
detached: true,
|
|
36916
|
-
env: {
|
|
36999
|
+
env: {
|
|
37000
|
+
...process.env,
|
|
37001
|
+
GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
|
|
37002
|
+
GDHARNESS_LSP_PORT: String(ports.lsp),
|
|
37003
|
+
GDHARNESS_DAP_PORT: String(ports.dap)
|
|
37004
|
+
}
|
|
36917
37005
|
});
|
|
36918
37006
|
const started = await new Promise((resolve) => {
|
|
36919
37007
|
editor.once("spawn", () => {
|
|
@@ -36930,9 +37018,16 @@ class GodotServer {
|
|
|
36930
37018
|
return this.jsonTextResponse({
|
|
36931
37019
|
launched: true,
|
|
36932
37020
|
pid: editor.pid ?? null,
|
|
36933
|
-
projectPath: project.value.path
|
|
37021
|
+
projectPath: project.value.path,
|
|
37022
|
+
lspPort: ports.lsp,
|
|
37023
|
+
dapPort: ports.dap
|
|
36934
37024
|
});
|
|
36935
37025
|
}
|
|
37026
|
+
async portsForAnEditor() {
|
|
37027
|
+
const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
|
|
37028
|
+
const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
|
|
37029
|
+
return { lsp, dap };
|
|
37030
|
+
}
|
|
36936
37031
|
async handleRunProject(args, op) {
|
|
36937
37032
|
const project = this.project(args);
|
|
36938
37033
|
if (!project.ok) {
|
|
@@ -37000,7 +37095,7 @@ class GodotServer {
|
|
|
37000
37095
|
await this.dap().connect();
|
|
37001
37096
|
} catch (error) {
|
|
37002
37097
|
return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
|
|
37003
|
-
|
|
37098
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
37004
37099
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
37005
37100
|
]);
|
|
37006
37101
|
}
|
|
@@ -37022,6 +37117,7 @@ class GodotServer {
|
|
|
37022
37117
|
through: "editor",
|
|
37023
37118
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
37024
37119
|
refreshedClasses,
|
|
37120
|
+
debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
|
|
37025
37121
|
message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
|
|
37026
37122
|
});
|
|
37027
37123
|
}
|
|
@@ -37204,7 +37300,7 @@ class GodotServer {
|
|
|
37204
37300
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
37205
37301
|
const { op: _op, projectPath, ...params } = asParams(args);
|
|
37206
37302
|
const announced = runtimesAnnounced();
|
|
37207
|
-
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
|
|
37303
|
+
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
|
|
37208
37304
|
if ("problem" in choice) {
|
|
37209
37305
|
return this.createErrorResponse(choice.problem);
|
|
37210
37306
|
}
|
|
@@ -37312,6 +37408,7 @@ var init_server3 = __esm(() => {
|
|
|
37312
37408
|
init_junit();
|
|
37313
37409
|
init_lsp_client();
|
|
37314
37410
|
init_paths();
|
|
37411
|
+
init_ports();
|
|
37315
37412
|
init_project_scan();
|
|
37316
37413
|
init_resources();
|
|
37317
37414
|
init_runtime_client();
|
|
@@ -38635,6 +38732,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
38635
38732
|
- Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
|
|
38636
38733
|
with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
|
|
38637
38734
|
from being known.
|
|
38735
|
+
- One server, one editor. A second project is a second harness session with its own server, and
|
|
38736
|
+
that works: each editor is opened on its own language server and debug adapter ports, and says
|
|
38737
|
+
where it serves. Two editors on one project is the thing to refuse.
|
|
38638
38738
|
|
|
38639
38739
|
## Measuring a running game
|
|
38640
38740
|
|
|
@@ -38645,6 +38745,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
38645
38745
|
| Press a button | \`runtime_input click\`, which says what was under the pointer |
|
|
38646
38746
|
| Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` |
|
|
38647
38747
|
| Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
|
|
38748
|
+
| Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
|
|
38648
38749
|
| Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
|
|
38649
38750
|
| Wait for something | \`runtime_wait\`, never a sleep |
|
|
38650
38751
|
| A picture, for a person who asked to see one | \`runtime_capture\` |
|
|
@@ -18,6 +18,17 @@ const ANNOUNCE_PROTOCOL: int = 1
|
|
|
18
18
|
const RECONNECT_DELAY: float = 3.0
|
|
19
19
|
const MAX_RECONNECT_DELAY: float = 30.0
|
|
20
20
|
|
|
21
|
+
## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
|
|
22
|
+
## file is one for every editor on the machine, so two open at once want the same three and the
|
|
23
|
+
## second one binds nothing.
|
|
24
|
+
const LSP_SETTING: String = "network/language_server/remote_port"
|
|
25
|
+
const DAP_SETTING: String = "network/debug_adapter/remote_port"
|
|
26
|
+
const DEBUGGER_SETTING: String = "network/debug/remote_port"
|
|
27
|
+
## What a server that opened this editor put in the environment, matching the ports it named on
|
|
28
|
+
## the command line. Kept in step with `editorArguments` in src/launch.ts.
|
|
29
|
+
const LSP_ASKED: String = "GDHARNESS_LSP_PORT"
|
|
30
|
+
const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
|
|
31
|
+
|
|
21
32
|
## How long one attempt is given before the address is called a bad one.
|
|
22
33
|
##
|
|
23
34
|
## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
|
|
@@ -62,6 +73,7 @@ var _connecting_for: float = 0.0
|
|
|
62
73
|
func _ready() -> void:
|
|
63
74
|
_project_path = ProjectSettings.globalize_path("res://")
|
|
64
75
|
version_at_load = _loaded_version()
|
|
76
|
+
_keep_the_ports_this_editor_was_given()
|
|
65
77
|
|
|
66
78
|
_reconnect_timer = Timer.new()
|
|
67
79
|
_reconnect_timer.one_shot = true
|
|
@@ -243,18 +255,73 @@ func _handle_connect() -> void:
|
|
|
243
255
|
# somebody restarts it, and nothing else can tell the two apart.
|
|
244
256
|
# The process id with it, because a restarted editor is a different process from the one
|
|
245
257
|
# whoever started it is holding, and nothing else says which one is now on the other end.
|
|
258
|
+
# The three ports with that, because a server that assumes the defaults talks to whichever
|
|
259
|
+
# editor took them, which on a machine running two is not this one.
|
|
246
260
|
_send_message(
|
|
247
261
|
{
|
|
248
262
|
"type": "godot_ready",
|
|
249
263
|
"project_path": _project_path,
|
|
250
264
|
"addon_version": version_at_load,
|
|
251
|
-
"editor_pid": OS.get_process_id()
|
|
265
|
+
"editor_pid": OS.get_process_id(),
|
|
266
|
+
"lsp_port": _serves(LSP_ASKED, LSP_SETTING),
|
|
267
|
+
"dap_port": _serves(DAP_ASKED, DAP_SETTING),
|
|
268
|
+
"debug_port": _serving(DEBUGGER_SETTING)
|
|
252
269
|
}
|
|
253
270
|
)
|
|
254
271
|
|
|
255
272
|
connected.emit()
|
|
256
273
|
|
|
257
274
|
|
|
275
|
+
## Writes the ports this editor was started on into the settings it reads them from.
|
|
276
|
+
##
|
|
277
|
+
## The command line moved the language server and the debug adapter for this run and the engine
|
|
278
|
+
## keeps that override to itself: the setting still reads whatever it read before, so an editor
|
|
279
|
+
## that restarts itself comes back on the old number and lands on top of whichever editor holds
|
|
280
|
+
## it. Writing it here is what makes the move survive a restart, and what leaves one place either
|
|
281
|
+
## side has to read.
|
|
282
|
+
func _keep_the_ports_this_editor_was_given() -> void:
|
|
283
|
+
if not Engine.is_editor_hint():
|
|
284
|
+
return
|
|
285
|
+
_keep_port(LSP_ASKED, LSP_SETTING)
|
|
286
|
+
_keep_port(DAP_ASKED, DAP_SETTING)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
func _keep_port(variable: String, setting: String) -> void:
|
|
290
|
+
var port: int = _asked_for(variable)
|
|
291
|
+
if port < 1 or port == _serving(setting):
|
|
292
|
+
return
|
|
293
|
+
var settings: EditorSettings = EditorInterface.get_editor_settings()
|
|
294
|
+
if settings != null:
|
|
295
|
+
settings.set_setting(setting, port)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
## What this editor serves: what it was told to when a server opened it, and what its settings
|
|
299
|
+
## say otherwise. The first is the one that counts, because the settings are the thing the command
|
|
300
|
+
## line was overriding.
|
|
301
|
+
func _serves(variable: String, setting: String) -> int:
|
|
302
|
+
var asked: int = _asked_for(variable)
|
|
303
|
+
return asked if asked > 0 else _serving(setting)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
## The port this variable names, or 0 for anything that is not one.
|
|
307
|
+
static func _asked_for(variable: String) -> int:
|
|
308
|
+
var said: String = OS.get_environment(variable)
|
|
309
|
+
if not said.is_valid_int():
|
|
310
|
+
return 0
|
|
311
|
+
var port: int = int(said)
|
|
312
|
+
return port if port >= 1 and port <= 65535 else 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
## What the settings say this editor serves on, or 0 when there is nothing to ask.
|
|
316
|
+
func _serving(setting: String) -> int:
|
|
317
|
+
if not Engine.is_editor_hint():
|
|
318
|
+
return 0
|
|
319
|
+
var settings: EditorSettings = EditorInterface.get_editor_settings()
|
|
320
|
+
if settings == null or not settings.has_setting(setting):
|
|
321
|
+
return 0
|
|
322
|
+
return int(settings.get_setting(setting))
|
|
323
|
+
|
|
324
|
+
|
|
258
325
|
## The version marker beside this addon, or "" when the copy was not installed by gdharness.
|
|
259
326
|
##
|
|
260
327
|
## Read once, when this copy loads, and never again: an upgrade replaces the files under a
|
|
@@ -10,6 +10,10 @@ extends Node
|
|
|
10
10
|
|
|
11
11
|
## What the display server calls itself when the engine was started with no display at all.
|
|
12
12
|
const HEADLESS_DISPLAY: String = "headless"
|
|
13
|
+
## Where Godot keeps the port its own debugger listens on for a game it is playing. One setting
|
|
14
|
+
## for every editor on the machine, and bound only while a game runs.
|
|
15
|
+
const DEBUGGER_SETTING: String = "network/debug/remote_port"
|
|
16
|
+
const LOOPBACK: String = "127.0.0.1"
|
|
13
17
|
|
|
14
18
|
var _editor_plugin: EditorPlugin = null
|
|
15
19
|
|
|
@@ -24,6 +28,8 @@ func play_scene(args: Dictionary) -> Dictionary:
|
|
|
24
28
|
if EditorInterface.is_playing_scene():
|
|
25
29
|
EditorInterface.stop_playing_scene()
|
|
26
30
|
|
|
31
|
+
var debugger: int = _a_port_for_the_debugger()
|
|
32
|
+
|
|
27
33
|
if scene_path.is_empty():
|
|
28
34
|
EditorInterface.play_main_scene()
|
|
29
35
|
else:
|
|
@@ -35,10 +41,43 @@ func play_scene(args: Dictionary) -> Dictionary:
|
|
|
35
41
|
return {
|
|
36
42
|
"ok": true,
|
|
37
43
|
"playing": EditorInterface.is_playing_scene(),
|
|
38
|
-
"scenePath": EditorInterface.get_playing_scene()
|
|
44
|
+
"scenePath": EditorInterface.get_playing_scene(),
|
|
45
|
+
"debugPort": debugger
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
|
|
49
|
+
## Gives this editor's debugger a port of its own, and answers the one the game will be sent to.
|
|
50
|
+
##
|
|
51
|
+
## The editor binds this while a game runs, out of a setting shared by every editor on the
|
|
52
|
+
## machine, and Godot takes --lsp-port and --dap-port on its command line but nothing at all for
|
|
53
|
+
## this one. So two editors playing at once want the same number, and both ways that can go are
|
|
54
|
+
## wrong: a bind that fails leaves a game with no debugger behind it, which is every debug tool
|
|
55
|
+
## and the whole console gone, and a bind that succeeds anyway leaves two editors on one port with
|
|
56
|
+
## the games going to whichever one the operating system picks.
|
|
57
|
+
##
|
|
58
|
+
## Asked of the operating system each time rather than tested first, which is how the runtime
|
|
59
|
+
## addon takes its own port: a port that was free a moment ago is not a port that is still free,
|
|
60
|
+
## and nothing this can ask distinguishes the editor's own debugger from another editor's.
|
|
61
|
+
func _a_port_for_the_debugger() -> int:
|
|
62
|
+
var settings: EditorSettings = EditorInterface.get_editor_settings()
|
|
63
|
+
if settings == null or not settings.has_setting(DEBUGGER_SETTING):
|
|
64
|
+
return 0
|
|
65
|
+
var spare: int = _any_free_port()
|
|
66
|
+
if spare > 0:
|
|
67
|
+
settings.set_setting(DEBUGGER_SETTING, spare)
|
|
68
|
+
return spare
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## A port the operating system handed out, or 0 when it would not give one.
|
|
72
|
+
static func _any_free_port() -> int:
|
|
73
|
+
var probe: TCPServer = TCPServer.new()
|
|
74
|
+
if probe.listen(0, LOOPBACK) != OK:
|
|
75
|
+
return 0
|
|
76
|
+
var port: int = probe.get_local_port()
|
|
77
|
+
probe.stop()
|
|
78
|
+
return port
|
|
79
|
+
|
|
80
|
+
|
|
42
81
|
func stop_playing(_args: Dictionary) -> Dictionary:
|
|
43
82
|
var was_playing: bool = EditorInterface.is_playing_scene()
|
|
44
83
|
if was_playing:
|
|
@@ -88,6 +127,17 @@ func restart_editor(_args: Dictionary) -> Dictionary:
|
|
|
88
127
|
|
|
89
128
|
func playing_status(_args: Dictionary) -> Dictionary:
|
|
90
129
|
var playing: bool = EditorInterface.is_playing_scene()
|
|
130
|
+
# The debugger's port with it, read now rather than remembered: it is taken again before every
|
|
131
|
+
# play, so the one from when this editor greeted the server is a number it has moved off.
|
|
132
|
+
var settings: EditorSettings = EditorInterface.get_editor_settings()
|
|
133
|
+
var debugger: int = (
|
|
134
|
+
int(settings.get_setting(DEBUGGER_SETTING))
|
|
135
|
+
if settings != null and settings.has_setting(DEBUGGER_SETTING)
|
|
136
|
+
else 0
|
|
137
|
+
)
|
|
91
138
|
return {
|
|
92
|
-
"ok": true,
|
|
139
|
+
"ok": true,
|
|
140
|
+
"playing": playing,
|
|
141
|
+
"scenePath": EditorInterface.get_playing_scene() if playing else "",
|
|
142
|
+
"debugPort": debugger
|
|
93
143
|
}
|
|
@@ -70,6 +70,7 @@ func _init() -> void:
|
|
|
70
70
|
"inject_mouse_click": _input.inject_mouse_click,
|
|
71
71
|
"inject_mouse_motion": _input.inject_mouse_motion,
|
|
72
72
|
"click": _input.click,
|
|
73
|
+
"choose": _input.choose,
|
|
73
74
|
"wait_frames": _waits.wait_frames,
|
|
74
75
|
"wait_signal": _waits.wait_signal,
|
|
75
76
|
"wait_until": _waits.wait_until,
|
|
@@ -400,6 +400,158 @@ func _no_window_note(viewport: Viewport) -> String:
|
|
|
400
400
|
return ". This game has no window: run it with a window to reach this control"
|
|
401
401
|
|
|
402
402
|
|
|
403
|
+
## Chooses an item out of a menu, by what it says or by where it is in the list.
|
|
404
|
+
##
|
|
405
|
+
## A menu's items are drawn rather than built, so there is no node under the pointer to aim at and
|
|
406
|
+
## no rectangle to ask for: [PopupMenu] exposes their text, their ids and which one has the focus,
|
|
407
|
+
## and nothing about where any of them is. So a click cannot reach one, and a whole click on the
|
|
408
|
+
## [OptionButton] in front of it opens the menu on the press and closes it again on the release.
|
|
409
|
+
## Every language picker, every filter and every dropdown in a game was unreachable, and the way
|
|
410
|
+
## past it was to call `select` and emit `item_selected`, which sets a number and runs none of the
|
|
411
|
+
## engine's own path.
|
|
412
|
+
##
|
|
413
|
+
## Chosen the way a keyboard chooses: the item takes the focus and then Enter presses it, which is
|
|
414
|
+
## the same route through [PopupMenu] a pointer takes and which needs no geometry, so it works in a
|
|
415
|
+
## game with no window as well.
|
|
416
|
+
##
|
|
417
|
+
## [param path] may be the menu or the button in front of it. Naming the button is what a caller
|
|
418
|
+
## has, since the menu is an internal child with a generated name that changes between runs.
|
|
419
|
+
func choose(params: Dictionary) -> Dictionary:
|
|
420
|
+
var node_path: String = str(params.get("path", ""))
|
|
421
|
+
if node_path.is_empty():
|
|
422
|
+
return {"type": "error", "message": "Node path required"}
|
|
423
|
+
var node: Node = _host.get_tree().root.get_node_or_null(node_path)
|
|
424
|
+
if node == null:
|
|
425
|
+
return {"type": "error", "message": "Node not found: " + node_path}
|
|
426
|
+
|
|
427
|
+
var menu: PopupMenu = _menu_of(node)
|
|
428
|
+
if menu == null:
|
|
429
|
+
return {
|
|
430
|
+
"type": "error",
|
|
431
|
+
"message":
|
|
432
|
+
(
|
|
433
|
+
"%s is a %s, which is neither a PopupMenu nor something holding one"
|
|
434
|
+
% [node_path, node.get_class()]
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if not params.has("index") and str(params.get("text", "")).is_empty():
|
|
439
|
+
return {
|
|
440
|
+
"type": "error",
|
|
441
|
+
"message":
|
|
442
|
+
(
|
|
443
|
+
"%s needs the item named, by text or index. It holds: %s"
|
|
444
|
+
% [node_path, ", ".join(_items_of(menu))]
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
var index: int = _wanted_item(menu, params)
|
|
449
|
+
if index < 0:
|
|
450
|
+
return {
|
|
451
|
+
"type": "error",
|
|
452
|
+
"message": "%s has no such item. It holds: %s" % [node_path, ", ".join(_items_of(menu))]
|
|
453
|
+
}
|
|
454
|
+
if menu.is_item_separator(index):
|
|
455
|
+
return {"type": "error", "message": "%s item %d is a separator, not a choice" % [node_path, index]}
|
|
456
|
+
if menu.is_item_disabled(index):
|
|
457
|
+
return {
|
|
458
|
+
"type": "error",
|
|
459
|
+
"message": "%s item %d, %s, is disabled" % [node_path, index, menu.get_item_text(index)]
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# Shown first, because a menu nobody has opened has no focus to move and Enter would go to
|
|
463
|
+
# whatever is behind it. An OptionButton opens its own; a bare PopupMenu is popped where it
|
|
464
|
+
# already sits, which leaves a menu that was already open where it is.
|
|
465
|
+
var opened: bool = _open_the_menu(node, menu)
|
|
466
|
+
await _host.get_tree().process_frame
|
|
467
|
+
|
|
468
|
+
menu.scroll_to_item(index)
|
|
469
|
+
menu.set_focused_item(index)
|
|
470
|
+
# Through Input rather than pushed at the menu, which is how a keyboard reaches an open one: a
|
|
471
|
+
# popup is a Window, it takes the focus when it opens, and Input delivers to whichever window
|
|
472
|
+
# has it. Pushed straight at the menu the event arrived and nothing happened.
|
|
473
|
+
Input.parse_input_event(_accept(true))
|
|
474
|
+
await _host.get_tree().process_frame
|
|
475
|
+
Input.parse_input_event(_accept(false))
|
|
476
|
+
await _host.get_tree().process_frame
|
|
477
|
+
|
|
478
|
+
var answer: Dictionary = {
|
|
479
|
+
"type": "chosen",
|
|
480
|
+
"path": node_path,
|
|
481
|
+
"index": index,
|
|
482
|
+
"text": menu.get_item_text(index),
|
|
483
|
+
"id": menu.get_item_id(index),
|
|
484
|
+
"opened": opened,
|
|
485
|
+
"menu": str(menu.get_path()),
|
|
486
|
+
}
|
|
487
|
+
# What the button in front of the menu reads now, which is the answer to "did it take": a menu
|
|
488
|
+
# item that fired changes the thing holding it, and nothing else about the press says so.
|
|
489
|
+
var chooser: OptionButton = node as OptionButton
|
|
490
|
+
if chooser != null:
|
|
491
|
+
answer["selected"] = chooser.get_selected()
|
|
492
|
+
answer["shows"] = chooser.text
|
|
493
|
+
return answer
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
## The menu [param node] is, or the one it holds. An [OptionButton] and a [MenuButton] both keep
|
|
497
|
+
## theirs as an internal child, which is a node a caller cannot name and should not have to.
|
|
498
|
+
static func _menu_of(node: Node) -> PopupMenu:
|
|
499
|
+
var menu: PopupMenu = node as PopupMenu
|
|
500
|
+
if menu != null:
|
|
501
|
+
return menu
|
|
502
|
+
if node.has_method("get_popup"):
|
|
503
|
+
var held: Variant = node.call("get_popup")
|
|
504
|
+
if held is PopupMenu:
|
|
505
|
+
return held
|
|
506
|
+
return null
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
|
|
510
|
+
## Minus one when neither names one that is there.
|
|
511
|
+
static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
|
|
512
|
+
if params.has("index"):
|
|
513
|
+
var asked: int = int(params.get("index", -1))
|
|
514
|
+
return asked if asked >= 0 and asked < menu.get_item_count() else -1
|
|
515
|
+
var wanted: String = str(params.get("text", ""))
|
|
516
|
+
if wanted.is_empty():
|
|
517
|
+
return -1
|
|
518
|
+
for index: int in menu.get_item_count():
|
|
519
|
+
if menu.get_item_text(index) == wanted:
|
|
520
|
+
return index
|
|
521
|
+
for index: int in menu.get_item_count():
|
|
522
|
+
if menu.get_item_text(index).nocasecmp_to(wanted) == 0:
|
|
523
|
+
return index
|
|
524
|
+
return -1
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
## What the menu says, for a refusal that names the choices rather than the miss.
|
|
528
|
+
static func _items_of(menu: PopupMenu) -> PackedStringArray:
|
|
529
|
+
var said: PackedStringArray = PackedStringArray()
|
|
530
|
+
for index: int in menu.get_item_count():
|
|
531
|
+
said.append("%d: %s" % [index, menu.get_item_text(index)])
|
|
532
|
+
return said
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
## Opens the menu if it is not already, and answers whether anything opened.
|
|
536
|
+
static func _open_the_menu(node: Node, menu: PopupMenu) -> bool:
|
|
537
|
+
if menu.visible:
|
|
538
|
+
return false
|
|
539
|
+
if node.has_method("show_popup"):
|
|
540
|
+
node.call("show_popup")
|
|
541
|
+
return true
|
|
542
|
+
menu.popup()
|
|
543
|
+
return true
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
func _accept(pressed: bool) -> InputEventKey:
|
|
547
|
+
var event: InputEventKey = InputEventKey.new()
|
|
548
|
+
event.keycode = KEY_ENTER
|
|
549
|
+
event.physical_keycode = KEY_ENTER
|
|
550
|
+
event.key_label = KEY_ENTER
|
|
551
|
+
event.pressed = pressed
|
|
552
|
+
return event
|
|
553
|
+
|
|
554
|
+
|
|
403
555
|
## A whole click aimed at where a 3D node is drawn, for a game that picks with a ray out of the
|
|
404
556
|
## cursor rather than with a Control.
|
|
405
557
|
##
|
package/build/index.js
CHANGED
|
@@ -24861,10 +24861,11 @@ class FrameReader {
|
|
|
24861
24861
|
}
|
|
24862
24862
|
|
|
24863
24863
|
// src/ports.ts
|
|
24864
|
-
|
|
24864
|
+
import { createServer } from "node:net";
|
|
24865
|
+
function portFromEnvOrNull(variable) {
|
|
24865
24866
|
const raw = process.env[variable]?.trim();
|
|
24866
24867
|
if (!raw) {
|
|
24867
|
-
return
|
|
24868
|
+
return null;
|
|
24868
24869
|
}
|
|
24869
24870
|
const parsed = Number.parseInt(raw, 10);
|
|
24870
24871
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
|
|
@@ -24872,6 +24873,36 @@ function portFromEnv(variable, fallback) {
|
|
|
24872
24873
|
}
|
|
24873
24874
|
return parsed;
|
|
24874
24875
|
}
|
|
24876
|
+
function portFromEnv(variable, fallback) {
|
|
24877
|
+
return portFromEnvOrNull(variable) ?? fallback;
|
|
24878
|
+
}
|
|
24879
|
+
async function freePort(preferred, host = "127.0.0.1") {
|
|
24880
|
+
const asked = await bindable(preferred, host);
|
|
24881
|
+
if (asked !== null) {
|
|
24882
|
+
return asked;
|
|
24883
|
+
}
|
|
24884
|
+
const spare = await bindable(0, host);
|
|
24885
|
+
if (spare === null) {
|
|
24886
|
+
throw new Refusal(`No port could be bound on ${host}.`);
|
|
24887
|
+
}
|
|
24888
|
+
return spare;
|
|
24889
|
+
}
|
|
24890
|
+
function bindable(port, host) {
|
|
24891
|
+
return new Promise((resolve) => {
|
|
24892
|
+
const probe = createServer();
|
|
24893
|
+
probe.once("error", () => {
|
|
24894
|
+
resolve(null);
|
|
24895
|
+
});
|
|
24896
|
+
probe.once("listening", () => {
|
|
24897
|
+
const bound = probe.address();
|
|
24898
|
+
const taken = typeof bound === "object" && bound !== null ? bound.port : port;
|
|
24899
|
+
probe.close(() => {
|
|
24900
|
+
resolve(taken);
|
|
24901
|
+
});
|
|
24902
|
+
});
|
|
24903
|
+
probe.listen(port, host);
|
|
24904
|
+
});
|
|
24905
|
+
}
|
|
24875
24906
|
|
|
24876
24907
|
// src/dap_client.ts
|
|
24877
24908
|
var DEFAULT_DAP_PORT = 6006;
|
|
@@ -25476,6 +25507,9 @@ function resolveDefaultBridgeHost() {
|
|
|
25476
25507
|
const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
|
|
25477
25508
|
return host === undefined || host === "" ? DEFAULT_HOST : host;
|
|
25478
25509
|
}
|
|
25510
|
+
function servedPort(said) {
|
|
25511
|
+
return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
|
|
25512
|
+
}
|
|
25479
25513
|
|
|
25480
25514
|
class GodotBridge extends EventEmitter {
|
|
25481
25515
|
httpServer = null;
|
|
@@ -25608,6 +25642,9 @@ class GodotBridge extends EventEmitter {
|
|
|
25608
25642
|
lastPongAt: this.connectionInfo?.lastPongAt,
|
|
25609
25643
|
addonVersion: this.connectionInfo?.addonVersion,
|
|
25610
25644
|
editorPid: this.connectionInfo?.editorPid,
|
|
25645
|
+
lspPort: this.connectionInfo?.lspPort,
|
|
25646
|
+
dapPort: this.connectionInfo?.dapPort,
|
|
25647
|
+
debugPort: this.connectionInfo?.debugPort,
|
|
25611
25648
|
pendingRequests: this.pendingRequests.size,
|
|
25612
25649
|
queuedResources: this.resourceQueues.size
|
|
25613
25650
|
};
|
|
@@ -25741,6 +25778,9 @@ class GodotBridge extends EventEmitter {
|
|
|
25741
25778
|
this.connectionInfo.projectPath = message.project_path;
|
|
25742
25779
|
this.connectionInfo.addonVersion = message.addon_version ?? "";
|
|
25743
25780
|
this.connectionInfo.editorPid = message.editor_pid;
|
|
25781
|
+
this.connectionInfo.lspPort = servedPort(message.lsp_port);
|
|
25782
|
+
this.connectionInfo.dapPort = servedPort(message.dap_port);
|
|
25783
|
+
this.connectionInfo.debugPort = servedPort(message.debug_port);
|
|
25744
25784
|
this.log("info", `Godot ready: ${message.project_path}`);
|
|
25745
25785
|
this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
|
|
25746
25786
|
}
|
|
@@ -26013,8 +26053,8 @@ function runArguments(options) {
|
|
|
26013
26053
|
}
|
|
26014
26054
|
return args;
|
|
26015
26055
|
}
|
|
26016
|
-
function editorArguments(projectPath) {
|
|
26017
|
-
return ["-e", "--path", projectPath];
|
|
26056
|
+
function editorArguments(projectPath, ports) {
|
|
26057
|
+
return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
|
|
26018
26058
|
}
|
|
26019
26059
|
function userDataIn(home, variables = process.env) {
|
|
26020
26060
|
const moved = ["APPDATA", "XDG_DATA_HOME"];
|
|
@@ -26911,10 +26951,10 @@ function asToolResponse(payload) {
|
|
|
26911
26951
|
]
|
|
26912
26952
|
};
|
|
26913
26953
|
}
|
|
26914
|
-
function normalizeLSPError(error) {
|
|
26954
|
+
function normalizeLSPError(error, port) {
|
|
26915
26955
|
if (error instanceof Error) {
|
|
26916
26956
|
if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
|
|
26917
|
-
return `Godot LSP is unavailable on port ${
|
|
26957
|
+
return `Godot LSP is unavailable on port ${port}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
|
|
26918
26958
|
}
|
|
26919
26959
|
return error.message;
|
|
26920
26960
|
}
|
|
@@ -26992,7 +27032,7 @@ async function handleLSPTool(client, toolName, args) {
|
|
|
26992
27032
|
}
|
|
26993
27033
|
} catch (error) {
|
|
26994
27034
|
return asToolResponse({
|
|
26995
|
-
error: normalizeLSPError(error)
|
|
27035
|
+
error: normalizeLSPError(error, client.port)
|
|
26996
27036
|
});
|
|
26997
27037
|
}
|
|
26998
27038
|
}
|
|
@@ -28348,12 +28388,12 @@ var TOOL_SPECS = [
|
|
|
28348
28388
|
},
|
|
28349
28389
|
{
|
|
28350
28390
|
name: "runtime_input",
|
|
28351
|
-
description: "Input to the running game: a whole click on a Control or a 3D node named by path, 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.",
|
|
28391
|
+
description: "Input to the running game: a whole click on a Control or a 3D node named by path, 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.",
|
|
28352
28392
|
parameters: {
|
|
28353
28393
|
projectPath: RUNNING_PROJECT_PATH,
|
|
28354
28394
|
nodePath: {
|
|
28355
28395
|
type: "string",
|
|
28356
|
-
description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn."
|
|
28396
|
+
description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
|
|
28357
28397
|
},
|
|
28358
28398
|
action: { type: "string", description: "action: the InputMap action name." },
|
|
28359
28399
|
pressed: { type: "boolean", description: "Press or release. Default true." },
|
|
@@ -28361,7 +28401,11 @@ var TOOL_SPECS = [
|
|
|
28361
28401
|
keycode: { type: "string", description: 'key: the key name, such as "Space" or "A".' },
|
|
28362
28402
|
text: {
|
|
28363
28403
|
type: "string",
|
|
28364
|
-
description: "text: what to type. A newline is Enter and a tab is Tab."
|
|
28404
|
+
description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
|
|
28405
|
+
},
|
|
28406
|
+
index: {
|
|
28407
|
+
type: "number",
|
|
28408
|
+
description: "choose: the item to take, by where it is in the list, when text will not do."
|
|
28365
28409
|
},
|
|
28366
28410
|
shift: { type: "boolean" },
|
|
28367
28411
|
ctrl: { type: "boolean" },
|
|
@@ -28383,6 +28427,10 @@ var TOOL_SPECS = [
|
|
|
28383
28427
|
summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
|
|
28384
28428
|
requires: ["nodePath"]
|
|
28385
28429
|
},
|
|
28430
|
+
choose: {
|
|
28431
|
+
summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
|
|
28432
|
+
requires: ["nodePath"]
|
|
28433
|
+
},
|
|
28386
28434
|
action: { summary: "press or release an action", requires: ["action"] },
|
|
28387
28435
|
key: { summary: "press or release a key", requires: ["keycode"] },
|
|
28388
28436
|
text: {
|
|
@@ -29080,12 +29128,23 @@ class GodotServer {
|
|
|
29080
29128
|
case "runtime_capture":
|
|
29081
29129
|
return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
|
|
29082
29130
|
case "runtime_input":
|
|
29083
|
-
|
|
29084
|
-
|
|
29085
|
-
|
|
29086
|
-
|
|
29087
|
-
|
|
29088
|
-
|
|
29131
|
+
if (op === "click") {
|
|
29132
|
+
return await this.handleRuntimeCommand("click", {
|
|
29133
|
+
projectPath: args["projectPath"],
|
|
29134
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
29135
|
+
button: readString(args, "button") ?? "left",
|
|
29136
|
+
double: readBoolean(args, "doubleClick") ?? false
|
|
29137
|
+
});
|
|
29138
|
+
}
|
|
29139
|
+
if (op === "choose") {
|
|
29140
|
+
return await this.handleRuntimeCommand("choose", {
|
|
29141
|
+
projectPath: args["projectPath"],
|
|
29142
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
29143
|
+
text: readString(args, "text") ?? "",
|
|
29144
|
+
...args["index"] === undefined ? {} : { index: args["index"] }
|
|
29145
|
+
});
|
|
29146
|
+
}
|
|
29147
|
+
return await this.handleRuntimeCommand(`inject_${op}`, args);
|
|
29089
29148
|
case "runtime_wait":
|
|
29090
29149
|
return await this.handleRuntimeWait(op, args);
|
|
29091
29150
|
case "debug_breakpoint": {
|
|
@@ -29166,7 +29225,7 @@ class GodotServer {
|
|
|
29166
29225
|
return {
|
|
29167
29226
|
ok: false,
|
|
29168
29227
|
response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
|
|
29169
|
-
|
|
29228
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
29170
29229
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
29171
29230
|
])
|
|
29172
29231
|
};
|
|
@@ -29533,7 +29592,8 @@ class GodotServer {
|
|
|
29533
29592
|
if (payload["error"] !== undefined) {
|
|
29534
29593
|
const reason = payload["error"];
|
|
29535
29594
|
return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
|
|
29536
|
-
|
|
29595
|
+
`Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
|
|
29596
|
+
"GDHARNESS_LSP_PORT points this server at another one"
|
|
29537
29597
|
]);
|
|
29538
29598
|
}
|
|
29539
29599
|
const diagnostics = readArray(payload, "diagnostics") ?? [];
|
|
@@ -29550,16 +29610,33 @@ class GodotServer {
|
|
|
29550
29610
|
});
|
|
29551
29611
|
}
|
|
29552
29612
|
async handleLSP(toolName, args) {
|
|
29553
|
-
this.
|
|
29613
|
+
const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
|
|
29614
|
+
if (this.lspClient !== null && this.lspClient.port !== port) {
|
|
29615
|
+
await this.lspClient.disconnect();
|
|
29616
|
+
this.lspClient = null;
|
|
29617
|
+
}
|
|
29618
|
+
this.lspClient ??= new GodotLSPClient(port);
|
|
29554
29619
|
return handleLSPTool(this.lspClient, toolName, args);
|
|
29555
29620
|
}
|
|
29556
29621
|
async handleDAP(toolName, args) {
|
|
29557
29622
|
return handleDAPTool(this.dap(), toolName, args);
|
|
29558
29623
|
}
|
|
29559
29624
|
dap() {
|
|
29560
|
-
this.
|
|
29625
|
+
const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
|
|
29626
|
+
if (this.dapClient !== null && this.dapClient.port !== port) {
|
|
29627
|
+
this.dapClient = null;
|
|
29628
|
+
}
|
|
29629
|
+
this.dapClient ??= new GodotDAPClient(port);
|
|
29561
29630
|
return this.dapClient;
|
|
29562
29631
|
}
|
|
29632
|
+
editorServes(field, variable, fallback) {
|
|
29633
|
+
const named = portFromEnvOrNull(variable);
|
|
29634
|
+
if (named !== null) {
|
|
29635
|
+
return named;
|
|
29636
|
+
}
|
|
29637
|
+
const status = this.godotBridge.getStatus();
|
|
29638
|
+
return (status.connected ? status[field] : undefined) ?? fallback;
|
|
29639
|
+
}
|
|
29563
29640
|
getEditorStatusPayload() {
|
|
29564
29641
|
const status = this.godotBridge.getStatus();
|
|
29565
29642
|
const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
|
|
@@ -29587,7 +29664,8 @@ class GodotServer {
|
|
|
29587
29664
|
const answer = await this.godotBridge.invokeTool("playing_status", {});
|
|
29588
29665
|
return {
|
|
29589
29666
|
playing: readBoolean(asParams(answer), "playing") ?? false,
|
|
29590
|
-
scene: readString(asParams(answer), "scenePath") ?? ""
|
|
29667
|
+
scene: readString(asParams(answer), "scenePath") ?? "",
|
|
29668
|
+
debugPort: readNumber(asParams(answer), "debugPort")
|
|
29591
29669
|
};
|
|
29592
29670
|
} catch {
|
|
29593
29671
|
return null;
|
|
@@ -29605,15 +29683,19 @@ class GodotServer {
|
|
|
29605
29683
|
problem: reply.ok ? null : reply.message
|
|
29606
29684
|
};
|
|
29607
29685
|
}));
|
|
29686
|
+
const playing = await this.editorPlayingState();
|
|
29608
29687
|
return this.jsonTextResponse({
|
|
29609
|
-
editor:
|
|
29688
|
+
editor: {
|
|
29689
|
+
...this.getEditorStatusPayload(),
|
|
29690
|
+
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
|
|
29691
|
+
},
|
|
29610
29692
|
godot: {
|
|
29611
29693
|
path: godotPath,
|
|
29612
29694
|
version: godotPath === null ? null : await this.godotVersion(godotPath)
|
|
29613
29695
|
},
|
|
29614
29696
|
game: {
|
|
29615
29697
|
processActive: this.activeProcess !== null,
|
|
29616
|
-
playingInEditor:
|
|
29698
|
+
playingInEditor: playing,
|
|
29617
29699
|
runtimeConnected: games.some((game) => game.reachable),
|
|
29618
29700
|
runtimes: games
|
|
29619
29701
|
}
|
|
@@ -29672,10 +29754,10 @@ class GodotServer {
|
|
|
29672
29754
|
return project.response;
|
|
29673
29755
|
}
|
|
29674
29756
|
if (this.godotBridge.isConnected()) {
|
|
29675
|
-
return this.createErrorResponse("An editor is already connected to this server
|
|
29757
|
+
return this.createErrorResponse("An editor is already connected to this server.", [
|
|
29676
29758
|
"editor_status says which editor is answering, and for which project",
|
|
29677
29759
|
"editor_launch restart replaces the connected editor rather than joining it",
|
|
29678
|
-
"
|
|
29760
|
+
"Another project wants its own gdharness server, which is its own harness session"
|
|
29679
29761
|
]);
|
|
29680
29762
|
}
|
|
29681
29763
|
const engine = await this.engine();
|
|
@@ -29683,10 +29765,16 @@ class GodotServer {
|
|
|
29683
29765
|
return engine.response;
|
|
29684
29766
|
}
|
|
29685
29767
|
this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
|
|
29686
|
-
const
|
|
29768
|
+
const ports = await this.portsForAnEditor();
|
|
29769
|
+
const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
|
|
29687
29770
|
stdio: "ignore",
|
|
29688
29771
|
detached: true,
|
|
29689
|
-
env: {
|
|
29772
|
+
env: {
|
|
29773
|
+
...process.env,
|
|
29774
|
+
GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
|
|
29775
|
+
GDHARNESS_LSP_PORT: String(ports.lsp),
|
|
29776
|
+
GDHARNESS_DAP_PORT: String(ports.dap)
|
|
29777
|
+
}
|
|
29690
29778
|
});
|
|
29691
29779
|
const started = await new Promise((resolve) => {
|
|
29692
29780
|
editor.once("spawn", () => {
|
|
@@ -29703,9 +29791,16 @@ class GodotServer {
|
|
|
29703
29791
|
return this.jsonTextResponse({
|
|
29704
29792
|
launched: true,
|
|
29705
29793
|
pid: editor.pid ?? null,
|
|
29706
|
-
projectPath: project.value.path
|
|
29794
|
+
projectPath: project.value.path,
|
|
29795
|
+
lspPort: ports.lsp,
|
|
29796
|
+
dapPort: ports.dap
|
|
29707
29797
|
});
|
|
29708
29798
|
}
|
|
29799
|
+
async portsForAnEditor() {
|
|
29800
|
+
const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
|
|
29801
|
+
const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
|
|
29802
|
+
return { lsp, dap };
|
|
29803
|
+
}
|
|
29709
29804
|
async handleRunProject(args, op) {
|
|
29710
29805
|
const project = this.project(args);
|
|
29711
29806
|
if (!project.ok) {
|
|
@@ -29773,7 +29868,7 @@ class GodotServer {
|
|
|
29773
29868
|
await this.dap().connect();
|
|
29774
29869
|
} catch (error) {
|
|
29775
29870
|
return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
|
|
29776
|
-
|
|
29871
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
29777
29872
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
29778
29873
|
]);
|
|
29779
29874
|
}
|
|
@@ -29795,6 +29890,7 @@ class GodotServer {
|
|
|
29795
29890
|
through: "editor",
|
|
29796
29891
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
29797
29892
|
refreshedClasses,
|
|
29893
|
+
debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
|
|
29798
29894
|
message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
|
|
29799
29895
|
});
|
|
29800
29896
|
}
|
|
@@ -29977,7 +30073,7 @@ class GodotServer {
|
|
|
29977
30073
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
29978
30074
|
const { op: _op, projectPath, ...params } = asParams(args);
|
|
29979
30075
|
const announced = runtimesAnnounced();
|
|
29980
|
-
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
|
|
30076
|
+
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
|
|
29981
30077
|
if ("problem" in choice) {
|
|
29982
30078
|
return this.createErrorResponse(choice.problem);
|
|
29983
30079
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"mcpName": "io.github.Aureliolo/gdharness",
|
|
5
5
|
"description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
|
|
6
6
|
"type": "module",
|