gdharness 0.6.3 → 0.6.5
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 +129 -27
- 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 +10 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +58 -0
- package/build/index.js +124 -26
- 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"];
|
|
@@ -8962,7 +8962,7 @@ var init_versions = __esm(() => {
|
|
|
8962
8962
|
version = {
|
|
8963
8963
|
major: 4,
|
|
8964
8964
|
minor: 6,
|
|
8965
|
-
patch:
|
|
8965
|
+
patch: 5
|
|
8966
8966
|
};
|
|
8967
8967
|
});
|
|
8968
8968
|
|
|
@@ -15744,12 +15744,12 @@ var init_tool_definitions = __esm(() => {
|
|
|
15744
15744
|
},
|
|
15745
15745
|
{
|
|
15746
15746
|
name: "runtime_inspect",
|
|
15747
|
-
description: "Questions about the running game: the scene tree, the nodes matching a query, where one node is on screen, what one property reads, or the performance metrics. Needs the game running with the runtime addon.",
|
|
15747
|
+
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, or the performance metrics. Needs the game running with the runtime addon.",
|
|
15748
15748
|
parameters: {
|
|
15749
15749
|
projectPath: RUNNING_PROJECT_PATH,
|
|
15750
15750
|
nodePath: {
|
|
15751
15751
|
type: "string",
|
|
15752
|
-
description: "tree, find: where to start, default /root. rect: the node to place. property: the node to read."
|
|
15752
|
+
description: "tree, find, text: where to start, default /root. rect: the node to place. property: the node to read."
|
|
15753
15753
|
},
|
|
15754
15754
|
property: {
|
|
15755
15755
|
type: "string",
|
|
@@ -15771,6 +15771,10 @@ var init_tool_definitions = __esm(() => {
|
|
|
15771
15771
|
},
|
|
15772
15772
|
group: { type: "string", description: "find: a group the node is in." },
|
|
15773
15773
|
limit: { type: "number", description: "find: the most nodes to answer with. Default 100." },
|
|
15774
|
+
includeHidden: {
|
|
15775
|
+
type: "boolean",
|
|
15776
|
+
description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
|
|
15777
|
+
},
|
|
15774
15778
|
metrics: {
|
|
15775
15779
|
type: "array",
|
|
15776
15780
|
items: { type: "string" },
|
|
@@ -15780,6 +15784,10 @@ var init_tool_definitions = __esm(() => {
|
|
|
15780
15784
|
requires: [],
|
|
15781
15785
|
operations: {
|
|
15782
15786
|
tree: { summary: "the live scene tree", requires: [] },
|
|
15787
|
+
text: {
|
|
15788
|
+
summary: "every line of text under nodePath, in the order somebody reads the screen, leaving out what is hidden and everything under it",
|
|
15789
|
+
requires: []
|
|
15790
|
+
},
|
|
15783
15791
|
find: {
|
|
15784
15792
|
summary: "the paths of every node matching className, script, namePattern or group, with property read off each",
|
|
15785
15793
|
requires: []
|
|
@@ -30791,10 +30799,11 @@ var init_framing = __esm(() => {
|
|
|
30791
30799
|
});
|
|
30792
30800
|
|
|
30793
30801
|
// src/ports.ts
|
|
30794
|
-
|
|
30802
|
+
import { createServer } from "node:net";
|
|
30803
|
+
function portFromEnvOrNull(variable) {
|
|
30795
30804
|
const raw = process.env[variable]?.trim();
|
|
30796
30805
|
if (!raw) {
|
|
30797
|
-
return
|
|
30806
|
+
return null;
|
|
30798
30807
|
}
|
|
30799
30808
|
const parsed = Number.parseInt(raw, 10);
|
|
30800
30809
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
|
|
@@ -30802,6 +30811,36 @@ function portFromEnv(variable, fallback) {
|
|
|
30802
30811
|
}
|
|
30803
30812
|
return parsed;
|
|
30804
30813
|
}
|
|
30814
|
+
function portFromEnv(variable, fallback) {
|
|
30815
|
+
return portFromEnvOrNull(variable) ?? fallback;
|
|
30816
|
+
}
|
|
30817
|
+
async function freePort(preferred, host = "127.0.0.1") {
|
|
30818
|
+
const asked = await bindable(preferred, host);
|
|
30819
|
+
if (asked !== null) {
|
|
30820
|
+
return asked;
|
|
30821
|
+
}
|
|
30822
|
+
const spare = await bindable(0, host);
|
|
30823
|
+
if (spare === null) {
|
|
30824
|
+
throw new Refusal2(`No port could be bound on ${host}.`);
|
|
30825
|
+
}
|
|
30826
|
+
return spare;
|
|
30827
|
+
}
|
|
30828
|
+
function bindable(port, host) {
|
|
30829
|
+
return new Promise((resolve) => {
|
|
30830
|
+
const probe = createServer();
|
|
30831
|
+
probe.once("error", () => {
|
|
30832
|
+
resolve(null);
|
|
30833
|
+
});
|
|
30834
|
+
probe.once("listening", () => {
|
|
30835
|
+
const bound = probe.address();
|
|
30836
|
+
const taken = typeof bound === "object" && bound !== null ? bound.port : port;
|
|
30837
|
+
probe.close(() => {
|
|
30838
|
+
resolve(taken);
|
|
30839
|
+
});
|
|
30840
|
+
});
|
|
30841
|
+
probe.listen(port, host);
|
|
30842
|
+
});
|
|
30843
|
+
}
|
|
30805
30844
|
var init_ports = __esm(() => {
|
|
30806
30845
|
init_errors();
|
|
30807
30846
|
});
|
|
@@ -34189,6 +34228,9 @@ function resolveDefaultBridgeHost() {
|
|
|
34189
34228
|
const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
|
|
34190
34229
|
return host === undefined || host === "" ? DEFAULT_HOST : host;
|
|
34191
34230
|
}
|
|
34231
|
+
function servedPort(said) {
|
|
34232
|
+
return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
|
|
34233
|
+
}
|
|
34192
34234
|
function getDefaultBridge(mayMove = false) {
|
|
34193
34235
|
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
|
|
34194
34236
|
return defaultBridge;
|
|
@@ -34329,6 +34371,9 @@ var init_godot_bridge = __esm(() => {
|
|
|
34329
34371
|
lastPongAt: this.connectionInfo?.lastPongAt,
|
|
34330
34372
|
addonVersion: this.connectionInfo?.addonVersion,
|
|
34331
34373
|
editorPid: this.connectionInfo?.editorPid,
|
|
34374
|
+
lspPort: this.connectionInfo?.lspPort,
|
|
34375
|
+
dapPort: this.connectionInfo?.dapPort,
|
|
34376
|
+
debugPort: this.connectionInfo?.debugPort,
|
|
34332
34377
|
pendingRequests: this.pendingRequests.size,
|
|
34333
34378
|
queuedResources: this.resourceQueues.size
|
|
34334
34379
|
};
|
|
@@ -34462,6 +34507,9 @@ var init_godot_bridge = __esm(() => {
|
|
|
34462
34507
|
this.connectionInfo.projectPath = message.project_path;
|
|
34463
34508
|
this.connectionInfo.addonVersion = message.addon_version ?? "";
|
|
34464
34509
|
this.connectionInfo.editorPid = message.editor_pid;
|
|
34510
|
+
this.connectionInfo.lspPort = servedPort(message.lsp_port);
|
|
34511
|
+
this.connectionInfo.dapPort = servedPort(message.dap_port);
|
|
34512
|
+
this.connectionInfo.debugPort = servedPort(message.debug_port);
|
|
34465
34513
|
this.log("info", `Godot ready: ${message.project_path}`);
|
|
34466
34514
|
this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
|
|
34467
34515
|
}
|
|
@@ -35368,10 +35416,10 @@ function asToolResponse(payload) {
|
|
|
35368
35416
|
]
|
|
35369
35417
|
};
|
|
35370
35418
|
}
|
|
35371
|
-
function normalizeLSPError(error) {
|
|
35419
|
+
function normalizeLSPError(error, port) {
|
|
35372
35420
|
if (error instanceof Error) {
|
|
35373
35421
|
if (error.message.includes("ECONNREFUSED") || error.message.includes("Failed to connect to Godot LSP") || error.message.includes("socket closed")) {
|
|
35374
|
-
return `Godot LSP is unavailable on port ${
|
|
35422
|
+
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.`;
|
|
35375
35423
|
}
|
|
35376
35424
|
return error.message;
|
|
35377
35425
|
}
|
|
@@ -35449,7 +35497,7 @@ async function handleLSPTool(client, toolName, args) {
|
|
|
35449
35497
|
}
|
|
35450
35498
|
} catch (error) {
|
|
35451
35499
|
return asToolResponse({
|
|
35452
|
-
error: normalizeLSPError(error)
|
|
35500
|
+
error: normalizeLSPError(error, client.port)
|
|
35453
35501
|
});
|
|
35454
35502
|
}
|
|
35455
35503
|
}
|
|
@@ -35804,6 +35852,9 @@ import { basename as basename2, dirname as dirname9, join as join15, normalize a
|
|
|
35804
35852
|
import { setTimeout as delay2 } from "node:timers/promises";
|
|
35805
35853
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
35806
35854
|
import { promisify as promisify5 } from "node:util";
|
|
35855
|
+
function patienceForFrames(frames, atLeast) {
|
|
35856
|
+
return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
|
|
35857
|
+
}
|
|
35807
35858
|
function escapingPropertyValue(properties) {
|
|
35808
35859
|
if (typeof properties !== "object" || properties === null) {
|
|
35809
35860
|
return;
|
|
@@ -36283,6 +36334,12 @@ class GodotServer {
|
|
|
36283
36334
|
});
|
|
36284
36335
|
case "find":
|
|
36285
36336
|
return await this.handleFindRuntimeNodes(args);
|
|
36337
|
+
case "text":
|
|
36338
|
+
return await this.handleRuntimeCommand("read_text", {
|
|
36339
|
+
projectPath: args["projectPath"],
|
|
36340
|
+
root: readNonEmptyString(args, "nodePath") ?? "/root",
|
|
36341
|
+
include_hidden: readBoolean(args, "includeHidden") ?? false
|
|
36342
|
+
});
|
|
36286
36343
|
case "rect":
|
|
36287
36344
|
return await this.handleRuntimeCommand("get_rect", {
|
|
36288
36345
|
projectPath: args["projectPath"],
|
|
@@ -36412,7 +36469,7 @@ class GodotServer {
|
|
|
36412
36469
|
return {
|
|
36413
36470
|
ok: false,
|
|
36414
36471
|
response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
|
|
36415
|
-
|
|
36472
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
36416
36473
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
36417
36474
|
])
|
|
36418
36475
|
};
|
|
@@ -36779,7 +36836,8 @@ class GodotServer {
|
|
|
36779
36836
|
if (payload["error"] !== undefined) {
|
|
36780
36837
|
const reason = payload["error"];
|
|
36781
36838
|
return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
|
|
36782
|
-
|
|
36839
|
+
`Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
|
|
36840
|
+
"GDHARNESS_LSP_PORT points this server at another one"
|
|
36783
36841
|
]);
|
|
36784
36842
|
}
|
|
36785
36843
|
const diagnostics = readArray(payload, "diagnostics") ?? [];
|
|
@@ -36796,16 +36854,33 @@ class GodotServer {
|
|
|
36796
36854
|
});
|
|
36797
36855
|
}
|
|
36798
36856
|
async handleLSP(toolName, args) {
|
|
36799
|
-
this.
|
|
36857
|
+
const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
|
|
36858
|
+
if (this.lspClient !== null && this.lspClient.port !== port) {
|
|
36859
|
+
await this.lspClient.disconnect();
|
|
36860
|
+
this.lspClient = null;
|
|
36861
|
+
}
|
|
36862
|
+
this.lspClient ??= new GodotLSPClient(port);
|
|
36800
36863
|
return handleLSPTool(this.lspClient, toolName, args);
|
|
36801
36864
|
}
|
|
36802
36865
|
async handleDAP(toolName, args) {
|
|
36803
36866
|
return handleDAPTool(this.dap(), toolName, args);
|
|
36804
36867
|
}
|
|
36805
36868
|
dap() {
|
|
36806
|
-
this.
|
|
36869
|
+
const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
|
|
36870
|
+
if (this.dapClient !== null && this.dapClient.port !== port) {
|
|
36871
|
+
this.dapClient = null;
|
|
36872
|
+
}
|
|
36873
|
+
this.dapClient ??= new GodotDAPClient(port);
|
|
36807
36874
|
return this.dapClient;
|
|
36808
36875
|
}
|
|
36876
|
+
editorServes(field, variable, fallback) {
|
|
36877
|
+
const named = portFromEnvOrNull(variable);
|
|
36878
|
+
if (named !== null) {
|
|
36879
|
+
return named;
|
|
36880
|
+
}
|
|
36881
|
+
const status = this.godotBridge.getStatus();
|
|
36882
|
+
return (status.connected ? status[field] : undefined) ?? fallback;
|
|
36883
|
+
}
|
|
36809
36884
|
getEditorStatusPayload() {
|
|
36810
36885
|
const status = this.godotBridge.getStatus();
|
|
36811
36886
|
const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
|
|
@@ -36833,7 +36908,8 @@ class GodotServer {
|
|
|
36833
36908
|
const answer = await this.godotBridge.invokeTool("playing_status", {});
|
|
36834
36909
|
return {
|
|
36835
36910
|
playing: readBoolean(asParams(answer), "playing") ?? false,
|
|
36836
|
-
scene: readString(asParams(answer), "scenePath") ?? ""
|
|
36911
|
+
scene: readString(asParams(answer), "scenePath") ?? "",
|
|
36912
|
+
debugPort: readNumber(asParams(answer), "debugPort")
|
|
36837
36913
|
};
|
|
36838
36914
|
} catch {
|
|
36839
36915
|
return null;
|
|
@@ -36851,15 +36927,19 @@ class GodotServer {
|
|
|
36851
36927
|
problem: reply.ok ? null : reply.message
|
|
36852
36928
|
};
|
|
36853
36929
|
}));
|
|
36930
|
+
const playing = await this.editorPlayingState();
|
|
36854
36931
|
return this.jsonTextResponse({
|
|
36855
|
-
editor:
|
|
36932
|
+
editor: {
|
|
36933
|
+
...this.getEditorStatusPayload(),
|
|
36934
|
+
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
|
|
36935
|
+
},
|
|
36856
36936
|
godot: {
|
|
36857
36937
|
path: godotPath,
|
|
36858
36938
|
version: godotPath === null ? null : await this.godotVersion(godotPath)
|
|
36859
36939
|
},
|
|
36860
36940
|
game: {
|
|
36861
36941
|
processActive: this.activeProcess !== null,
|
|
36862
|
-
playingInEditor:
|
|
36942
|
+
playingInEditor: playing,
|
|
36863
36943
|
runtimeConnected: games.some((game) => game.reachable),
|
|
36864
36944
|
runtimes: games
|
|
36865
36945
|
}
|
|
@@ -36918,10 +36998,10 @@ class GodotServer {
|
|
|
36918
36998
|
return project.response;
|
|
36919
36999
|
}
|
|
36920
37000
|
if (this.godotBridge.isConnected()) {
|
|
36921
|
-
return this.createErrorResponse("An editor is already connected to this server
|
|
37001
|
+
return this.createErrorResponse("An editor is already connected to this server.", [
|
|
36922
37002
|
"editor_status says which editor is answering, and for which project",
|
|
36923
37003
|
"editor_launch restart replaces the connected editor rather than joining it",
|
|
36924
|
-
"
|
|
37004
|
+
"Another project wants its own gdharness server, which is its own harness session"
|
|
36925
37005
|
]);
|
|
36926
37006
|
}
|
|
36927
37007
|
const engine = await this.engine();
|
|
@@ -36929,10 +37009,16 @@ class GodotServer {
|
|
|
36929
37009
|
return engine.response;
|
|
36930
37010
|
}
|
|
36931
37011
|
this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
|
|
36932
|
-
const
|
|
37012
|
+
const ports = await this.portsForAnEditor();
|
|
37013
|
+
const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
|
|
36933
37014
|
stdio: "ignore",
|
|
36934
37015
|
detached: true,
|
|
36935
|
-
env: {
|
|
37016
|
+
env: {
|
|
37017
|
+
...process.env,
|
|
37018
|
+
GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
|
|
37019
|
+
GDHARNESS_LSP_PORT: String(ports.lsp),
|
|
37020
|
+
GDHARNESS_DAP_PORT: String(ports.dap)
|
|
37021
|
+
}
|
|
36936
37022
|
});
|
|
36937
37023
|
const started = await new Promise((resolve) => {
|
|
36938
37024
|
editor.once("spawn", () => {
|
|
@@ -36949,9 +37035,16 @@ class GodotServer {
|
|
|
36949
37035
|
return this.jsonTextResponse({
|
|
36950
37036
|
launched: true,
|
|
36951
37037
|
pid: editor.pid ?? null,
|
|
36952
|
-
projectPath: project.value.path
|
|
37038
|
+
projectPath: project.value.path,
|
|
37039
|
+
lspPort: ports.lsp,
|
|
37040
|
+
dapPort: ports.dap
|
|
36953
37041
|
});
|
|
36954
37042
|
}
|
|
37043
|
+
async portsForAnEditor() {
|
|
37044
|
+
const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
|
|
37045
|
+
const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
|
|
37046
|
+
return { lsp, dap };
|
|
37047
|
+
}
|
|
36955
37048
|
async handleRunProject(args, op) {
|
|
36956
37049
|
const project = this.project(args);
|
|
36957
37050
|
if (!project.ok) {
|
|
@@ -37019,7 +37112,7 @@ class GodotServer {
|
|
|
37019
37112
|
await this.dap().connect();
|
|
37020
37113
|
} catch (error) {
|
|
37021
37114
|
return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
|
|
37022
|
-
|
|
37115
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
37023
37116
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
37024
37117
|
]);
|
|
37025
37118
|
}
|
|
@@ -37041,6 +37134,7 @@ class GodotServer {
|
|
|
37041
37134
|
through: "editor",
|
|
37042
37135
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
37043
37136
|
refreshedClasses,
|
|
37137
|
+
debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
|
|
37044
37138
|
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."
|
|
37045
37139
|
});
|
|
37046
37140
|
}
|
|
@@ -37223,7 +37317,7 @@ class GodotServer {
|
|
|
37223
37317
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
37224
37318
|
const { op: _op, projectPath, ...params } = asParams(args);
|
|
37225
37319
|
const announced = runtimesAnnounced();
|
|
37226
|
-
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
|
|
37320
|
+
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
|
|
37227
37321
|
if ("problem" in choice) {
|
|
37228
37322
|
return this.createErrorResponse(choice.problem);
|
|
37229
37323
|
}
|
|
@@ -37290,8 +37384,11 @@ class GodotServer {
|
|
|
37290
37384
|
const nodePath = readNonEmptyString(args, "nodePath") ?? "";
|
|
37291
37385
|
const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
|
|
37292
37386
|
switch (op) {
|
|
37293
|
-
case "frames":
|
|
37294
|
-
|
|
37387
|
+
case "frames": {
|
|
37388
|
+
const frames = readPositiveNumber(args, "frames") ?? 1;
|
|
37389
|
+
const waited = patienceForFrames(frames, patience);
|
|
37390
|
+
return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
|
|
37391
|
+
}
|
|
37295
37392
|
case "signal":
|
|
37296
37393
|
return await this.handleRuntimeCommand("wait_signal", {
|
|
37297
37394
|
projectPath: args["projectPath"],
|
|
@@ -37314,7 +37411,7 @@ async function runGodotServer() {
|
|
|
37314
37411
|
const server = new GodotServer;
|
|
37315
37412
|
await server.run();
|
|
37316
37413
|
}
|
|
37317
|
-
var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, BRIDGE_RETRY_MS = 2000, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS;
|
|
37414
|
+
var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, BRIDGE_RETRY_MS = 2000, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS;
|
|
37318
37415
|
var init_server3 = __esm(() => {
|
|
37319
37416
|
init_mcp();
|
|
37320
37417
|
init_stdio2();
|
|
@@ -37331,6 +37428,7 @@ var init_server3 = __esm(() => {
|
|
|
37331
37428
|
init_junit();
|
|
37332
37429
|
init_lsp_client();
|
|
37333
37430
|
init_paths();
|
|
37431
|
+
init_ports();
|
|
37334
37432
|
init_project_scan();
|
|
37335
37433
|
init_resources();
|
|
37336
37434
|
init_runtime_client();
|
|
@@ -38654,11 +38752,15 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
38654
38752
|
- Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
|
|
38655
38753
|
with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
|
|
38656
38754
|
from being known.
|
|
38755
|
+
- One server, one editor. A second project is a second harness session with its own server, and
|
|
38756
|
+
that works: each editor is opened on its own language server and debug adapter ports, and says
|
|
38757
|
+
where it serves. Two editors on one project is the thing to refuse.
|
|
38657
38758
|
|
|
38658
38759
|
## Measuring a running game
|
|
38659
38760
|
|
|
38660
38761
|
| Ask | Tool |
|
|
38661
38762
|
| --- | --- |
|
|
38763
|
+
| What a screen says | \`runtime_inspect text\`, which reads what is drawn and leaves out what is hidden |
|
|
38662
38764
|
| Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
|
|
38663
38765
|
| What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
|
|
38664
38766
|
| Press a button | \`runtime_input click\`, which says what was under the pointer |
|
|
@@ -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
|
}
|
|
@@ -57,6 +57,7 @@ func _init() -> void:
|
|
|
57
57
|
"ping": _ping,
|
|
58
58
|
"get_tree": _queries.get_tree,
|
|
59
59
|
"find_nodes": _queries.find_nodes,
|
|
60
|
+
"read_text": _queries.read_text,
|
|
60
61
|
"get_rect": _queries.get_rect,
|
|
61
62
|
"get_property": _queries.get_property,
|
|
62
63
|
"set_property": _queries.set_property,
|
|
@@ -435,6 +435,16 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
435
435
|
)
|
|
436
436
|
}
|
|
437
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
|
+
|
|
438
448
|
var index: int = _wanted_item(menu, params)
|
|
439
449
|
if index < 0:
|
|
440
450
|
return {
|
|
@@ -10,6 +10,10 @@ const Values = preload("runtime_values.gd")
|
|
|
10
10
|
const FIND_LIMIT: int = 100
|
|
11
11
|
const FIND_LIMIT_CEILING: int = 1000
|
|
12
12
|
|
|
13
|
+
## The most lines one read answers with. A screen is a few dozen; a thousand is a tree somebody
|
|
14
|
+
## pointed this at by mistake.
|
|
15
|
+
const READ_LIMIT: int = 500
|
|
16
|
+
|
|
13
17
|
var _host: Node
|
|
14
18
|
var _values: Values
|
|
15
19
|
|
|
@@ -138,6 +142,60 @@ func _matches(
|
|
|
138
142
|
## is drawn in, in both the canvas coordinates the node reports and the window pixels input
|
|
139
143
|
## arrives in. The two differ whenever the project stretches its viewport, which is what made a
|
|
140
144
|
## rect unusable for a click.
|
|
145
|
+
## Every line of text under a node, in the order somebody reads the screen.
|
|
146
|
+
##
|
|
147
|
+
## The question a caller asks most often, and the one that cost the most to answer: reading a panel
|
|
148
|
+
## was a find for every label with the path of each beside it, a few hundred characters apiece to
|
|
149
|
+
## carry a sentence of six words. Long is the smaller half of it. A find answers off nodes nobody
|
|
150
|
+
## can see, and a panel keeps its empty state in the tree beside its rows, so "Nothing posted" came
|
|
151
|
+
## back next to the three things posted and was believed twice in one session.
|
|
152
|
+
##
|
|
153
|
+
## A hidden node is left out and so is everything under it, because what a player reads is what is
|
|
154
|
+
## drawn. [param include_hidden] asks for the lot instead, which is what a caller checking that
|
|
155
|
+
## something is not showing wants.
|
|
156
|
+
func read_text(params: Dictionary) -> Dictionary:
|
|
157
|
+
var root_path: String = str(params.get("root", "/root"))
|
|
158
|
+
var include_hidden: bool = bool(params.get("include_hidden", false))
|
|
159
|
+
|
|
160
|
+
var root: Node = _host.get_tree().root.get_node_or_null(root_path)
|
|
161
|
+
if root == null:
|
|
162
|
+
return {"type": "error", "message": "Node not found: " + root_path}
|
|
163
|
+
|
|
164
|
+
var lines: PackedStringArray = PackedStringArray()
|
|
165
|
+
_read_into(root, include_hidden, lines)
|
|
166
|
+
return {
|
|
167
|
+
"type": "text",
|
|
168
|
+
"root": root_path,
|
|
169
|
+
"lines": lines,
|
|
170
|
+
"count": lines.size(),
|
|
171
|
+
"truncated": lines.size() >= READ_LIMIT,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
## Walks [param node] depth first, which is the order the screen is laid out in and the order a
|
|
176
|
+
## person reads it.
|
|
177
|
+
func _read_into(node: Node, include_hidden: bool, into: PackedStringArray) -> void:
|
|
178
|
+
if into.size() >= READ_LIMIT:
|
|
179
|
+
return
|
|
180
|
+
var control: CanvasItem = node as CanvasItem
|
|
181
|
+
if not include_hidden and control != null and not control.visible:
|
|
182
|
+
return
|
|
183
|
+
var said: String = _said_by(node)
|
|
184
|
+
if not said.is_empty():
|
|
185
|
+
into.append(said)
|
|
186
|
+
for child: Node in node.get_children():
|
|
187
|
+
_read_into(child, include_hidden, into)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
|
|
191
|
+
## is every label, button and field the interface is built out of.
|
|
192
|
+
static func _said_by(node: Node) -> String:
|
|
193
|
+
for property: Dictionary in node.get_property_list():
|
|
194
|
+
if str(property.get("name", "")) == "text" and int(property.get("type", 0)) == TYPE_STRING:
|
|
195
|
+
return str(node.get("text")).strip_edges()
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
|
|
141
199
|
func get_rect(params: Dictionary) -> Dictionary:
|
|
142
200
|
var node_path: String = str(params.get("path", ""))
|
|
143
201
|
if node_path.is_empty():
|
package/build/index.js
CHANGED
|
@@ -15311,7 +15311,7 @@ ${content.join(`
|
|
|
15311
15311
|
var version = {
|
|
15312
15312
|
major: 4,
|
|
15313
15313
|
minor: 6,
|
|
15314
|
-
patch:
|
|
15314
|
+
patch: 5
|
|
15315
15315
|
};
|
|
15316
15316
|
|
|
15317
15317
|
// node_modules/zod/v4/core/schemas.js
|
|
@@ -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
|
}
|
|
@@ -28258,12 +28298,12 @@ var TOOL_SPECS = [
|
|
|
28258
28298
|
},
|
|
28259
28299
|
{
|
|
28260
28300
|
name: "runtime_inspect",
|
|
28261
|
-
description: "Questions about the running game: the scene tree, the nodes matching a query, where one node is on screen, what one property reads, or the performance metrics. Needs the game running with the runtime addon.",
|
|
28301
|
+
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, or the performance metrics. Needs the game running with the runtime addon.",
|
|
28262
28302
|
parameters: {
|
|
28263
28303
|
projectPath: RUNNING_PROJECT_PATH,
|
|
28264
28304
|
nodePath: {
|
|
28265
28305
|
type: "string",
|
|
28266
|
-
description: "tree, find: where to start, default /root. rect: the node to place. property: the node to read."
|
|
28306
|
+
description: "tree, find, text: where to start, default /root. rect: the node to place. property: the node to read."
|
|
28267
28307
|
},
|
|
28268
28308
|
property: {
|
|
28269
28309
|
type: "string",
|
|
@@ -28285,6 +28325,10 @@ var TOOL_SPECS = [
|
|
|
28285
28325
|
},
|
|
28286
28326
|
group: { type: "string", description: "find: a group the node is in." },
|
|
28287
28327
|
limit: { type: "number", description: "find: the most nodes to answer with. Default 100." },
|
|
28328
|
+
includeHidden: {
|
|
28329
|
+
type: "boolean",
|
|
28330
|
+
description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
|
|
28331
|
+
},
|
|
28288
28332
|
metrics: {
|
|
28289
28333
|
type: "array",
|
|
28290
28334
|
items: { type: "string" },
|
|
@@ -28294,6 +28338,10 @@ var TOOL_SPECS = [
|
|
|
28294
28338
|
requires: [],
|
|
28295
28339
|
operations: {
|
|
28296
28340
|
tree: { summary: "the live scene tree", requires: [] },
|
|
28341
|
+
text: {
|
|
28342
|
+
summary: "every line of text under nodePath, in the order somebody reads the screen, leaving out what is hidden and everything under it",
|
|
28343
|
+
requires: []
|
|
28344
|
+
},
|
|
28297
28345
|
find: {
|
|
28298
28346
|
summary: "the paths of every node matching className, script, namePattern or group, with property read off each",
|
|
28299
28347
|
requires: []
|
|
@@ -28523,6 +28571,10 @@ var FEEDBACK_NOTICE_EVERY = 250;
|
|
|
28523
28571
|
var run3 = promisify3(execFile3);
|
|
28524
28572
|
var __dirname2 = dirname4(fileURLToPath2(import.meta.url));
|
|
28525
28573
|
var EDITOR_RESTART_TIMEOUT_MS = 90000;
|
|
28574
|
+
var SLOWEST_FRAME_RATE = 20;
|
|
28575
|
+
function patienceForFrames(frames, atLeast) {
|
|
28576
|
+
return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
|
|
28577
|
+
}
|
|
28526
28578
|
var BRIDGE_RETRY_MS = 2000;
|
|
28527
28579
|
var PATH_SOLUTIONS = [
|
|
28528
28580
|
'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
|
|
@@ -29056,6 +29108,12 @@ class GodotServer {
|
|
|
29056
29108
|
});
|
|
29057
29109
|
case "find":
|
|
29058
29110
|
return await this.handleFindRuntimeNodes(args);
|
|
29111
|
+
case "text":
|
|
29112
|
+
return await this.handleRuntimeCommand("read_text", {
|
|
29113
|
+
projectPath: args["projectPath"],
|
|
29114
|
+
root: readNonEmptyString(args, "nodePath") ?? "/root",
|
|
29115
|
+
include_hidden: readBoolean(args, "includeHidden") ?? false
|
|
29116
|
+
});
|
|
29059
29117
|
case "rect":
|
|
29060
29118
|
return await this.handleRuntimeCommand("get_rect", {
|
|
29061
29119
|
projectPath: args["projectPath"],
|
|
@@ -29185,7 +29243,7 @@ class GodotServer {
|
|
|
29185
29243
|
return {
|
|
29186
29244
|
ok: false,
|
|
29187
29245
|
response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
|
|
29188
|
-
|
|
29246
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
29189
29247
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
29190
29248
|
])
|
|
29191
29249
|
};
|
|
@@ -29552,7 +29610,8 @@ class GodotServer {
|
|
|
29552
29610
|
if (payload["error"] !== undefined) {
|
|
29553
29611
|
const reason = payload["error"];
|
|
29554
29612
|
return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
|
|
29555
|
-
|
|
29613
|
+
`Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
|
|
29614
|
+
"GDHARNESS_LSP_PORT points this server at another one"
|
|
29556
29615
|
]);
|
|
29557
29616
|
}
|
|
29558
29617
|
const diagnostics = readArray(payload, "diagnostics") ?? [];
|
|
@@ -29569,16 +29628,33 @@ class GodotServer {
|
|
|
29569
29628
|
});
|
|
29570
29629
|
}
|
|
29571
29630
|
async handleLSP(toolName, args) {
|
|
29572
|
-
this.
|
|
29631
|
+
const port = this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT);
|
|
29632
|
+
if (this.lspClient !== null && this.lspClient.port !== port) {
|
|
29633
|
+
await this.lspClient.disconnect();
|
|
29634
|
+
this.lspClient = null;
|
|
29635
|
+
}
|
|
29636
|
+
this.lspClient ??= new GodotLSPClient(port);
|
|
29573
29637
|
return handleLSPTool(this.lspClient, toolName, args);
|
|
29574
29638
|
}
|
|
29575
29639
|
async handleDAP(toolName, args) {
|
|
29576
29640
|
return handleDAPTool(this.dap(), toolName, args);
|
|
29577
29641
|
}
|
|
29578
29642
|
dap() {
|
|
29579
|
-
this.
|
|
29643
|
+
const port = this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT);
|
|
29644
|
+
if (this.dapClient !== null && this.dapClient.port !== port) {
|
|
29645
|
+
this.dapClient = null;
|
|
29646
|
+
}
|
|
29647
|
+
this.dapClient ??= new GodotDAPClient(port);
|
|
29580
29648
|
return this.dapClient;
|
|
29581
29649
|
}
|
|
29650
|
+
editorServes(field, variable, fallback) {
|
|
29651
|
+
const named = portFromEnvOrNull(variable);
|
|
29652
|
+
if (named !== null) {
|
|
29653
|
+
return named;
|
|
29654
|
+
}
|
|
29655
|
+
const status = this.godotBridge.getStatus();
|
|
29656
|
+
return (status.connected ? status[field] : undefined) ?? fallback;
|
|
29657
|
+
}
|
|
29582
29658
|
getEditorStatusPayload() {
|
|
29583
29659
|
const status = this.godotBridge.getStatus();
|
|
29584
29660
|
const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
|
|
@@ -29606,7 +29682,8 @@ class GodotServer {
|
|
|
29606
29682
|
const answer = await this.godotBridge.invokeTool("playing_status", {});
|
|
29607
29683
|
return {
|
|
29608
29684
|
playing: readBoolean(asParams(answer), "playing") ?? false,
|
|
29609
|
-
scene: readString(asParams(answer), "scenePath") ?? ""
|
|
29685
|
+
scene: readString(asParams(answer), "scenePath") ?? "",
|
|
29686
|
+
debugPort: readNumber(asParams(answer), "debugPort")
|
|
29610
29687
|
};
|
|
29611
29688
|
} catch {
|
|
29612
29689
|
return null;
|
|
@@ -29624,15 +29701,19 @@ class GodotServer {
|
|
|
29624
29701
|
problem: reply.ok ? null : reply.message
|
|
29625
29702
|
};
|
|
29626
29703
|
}));
|
|
29704
|
+
const playing = await this.editorPlayingState();
|
|
29627
29705
|
return this.jsonTextResponse({
|
|
29628
|
-
editor:
|
|
29706
|
+
editor: {
|
|
29707
|
+
...this.getEditorStatusPayload(),
|
|
29708
|
+
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
|
|
29709
|
+
},
|
|
29629
29710
|
godot: {
|
|
29630
29711
|
path: godotPath,
|
|
29631
29712
|
version: godotPath === null ? null : await this.godotVersion(godotPath)
|
|
29632
29713
|
},
|
|
29633
29714
|
game: {
|
|
29634
29715
|
processActive: this.activeProcess !== null,
|
|
29635
|
-
playingInEditor:
|
|
29716
|
+
playingInEditor: playing,
|
|
29636
29717
|
runtimeConnected: games.some((game) => game.reachable),
|
|
29637
29718
|
runtimes: games
|
|
29638
29719
|
}
|
|
@@ -29691,10 +29772,10 @@ class GodotServer {
|
|
|
29691
29772
|
return project.response;
|
|
29692
29773
|
}
|
|
29693
29774
|
if (this.godotBridge.isConnected()) {
|
|
29694
|
-
return this.createErrorResponse("An editor is already connected to this server
|
|
29775
|
+
return this.createErrorResponse("An editor is already connected to this server.", [
|
|
29695
29776
|
"editor_status says which editor is answering, and for which project",
|
|
29696
29777
|
"editor_launch restart replaces the connected editor rather than joining it",
|
|
29697
|
-
"
|
|
29778
|
+
"Another project wants its own gdharness server, which is its own harness session"
|
|
29698
29779
|
]);
|
|
29699
29780
|
}
|
|
29700
29781
|
const engine = await this.engine();
|
|
@@ -29702,10 +29783,16 @@ class GodotServer {
|
|
|
29702
29783
|
return engine.response;
|
|
29703
29784
|
}
|
|
29704
29785
|
this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
|
|
29705
|
-
const
|
|
29786
|
+
const ports = await this.portsForAnEditor();
|
|
29787
|
+
const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
|
|
29706
29788
|
stdio: "ignore",
|
|
29707
29789
|
detached: true,
|
|
29708
|
-
env: {
|
|
29790
|
+
env: {
|
|
29791
|
+
...process.env,
|
|
29792
|
+
GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
|
|
29793
|
+
GDHARNESS_LSP_PORT: String(ports.lsp),
|
|
29794
|
+
GDHARNESS_DAP_PORT: String(ports.dap)
|
|
29795
|
+
}
|
|
29709
29796
|
});
|
|
29710
29797
|
const started = await new Promise((resolve) => {
|
|
29711
29798
|
editor.once("spawn", () => {
|
|
@@ -29722,9 +29809,16 @@ class GodotServer {
|
|
|
29722
29809
|
return this.jsonTextResponse({
|
|
29723
29810
|
launched: true,
|
|
29724
29811
|
pid: editor.pid ?? null,
|
|
29725
|
-
projectPath: project.value.path
|
|
29812
|
+
projectPath: project.value.path,
|
|
29813
|
+
lspPort: ports.lsp,
|
|
29814
|
+
dapPort: ports.dap
|
|
29726
29815
|
});
|
|
29727
29816
|
}
|
|
29817
|
+
async portsForAnEditor() {
|
|
29818
|
+
const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
|
|
29819
|
+
const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
|
|
29820
|
+
return { lsp, dap };
|
|
29821
|
+
}
|
|
29728
29822
|
async handleRunProject(args, op) {
|
|
29729
29823
|
const project = this.project(args);
|
|
29730
29824
|
if (!project.ok) {
|
|
@@ -29792,7 +29886,7 @@ class GodotServer {
|
|
|
29792
29886
|
await this.dap().connect();
|
|
29793
29887
|
} catch (error) {
|
|
29794
29888
|
return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
|
|
29795
|
-
|
|
29889
|
+
`This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
|
|
29796
29890
|
"GDHARNESS_DAP_PORT points this server at another one"
|
|
29797
29891
|
]);
|
|
29798
29892
|
}
|
|
@@ -29814,6 +29908,7 @@ class GodotServer {
|
|
|
29814
29908
|
through: "editor",
|
|
29815
29909
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
29816
29910
|
refreshedClasses,
|
|
29911
|
+
debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
|
|
29817
29912
|
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."
|
|
29818
29913
|
});
|
|
29819
29914
|
}
|
|
@@ -29996,7 +30091,7 @@ class GodotServer {
|
|
|
29996
30091
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
29997
30092
|
const { op: _op, projectPath, ...params } = asParams(args);
|
|
29998
30093
|
const announced = runtimesAnnounced();
|
|
29999
|
-
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
|
|
30094
|
+
const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken);
|
|
30000
30095
|
if ("problem" in choice) {
|
|
30001
30096
|
return this.createErrorResponse(choice.problem);
|
|
30002
30097
|
}
|
|
@@ -30063,8 +30158,11 @@ class GodotServer {
|
|
|
30063
30158
|
const nodePath = readNonEmptyString(args, "nodePath") ?? "";
|
|
30064
30159
|
const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
|
|
30065
30160
|
switch (op) {
|
|
30066
|
-
case "frames":
|
|
30067
|
-
|
|
30161
|
+
case "frames": {
|
|
30162
|
+
const frames = readPositiveNumber(args, "frames") ?? 1;
|
|
30163
|
+
const waited = patienceForFrames(frames, patience);
|
|
30164
|
+
return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
|
|
30165
|
+
}
|
|
30068
30166
|
case "signal":
|
|
30069
30167
|
return await this.handleRuntimeCommand("wait_signal", {
|
|
30070
30168
|
projectPath: args["projectPath"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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",
|