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 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: 4
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
- function portFromEnv(variable, fallback) {
30802
+ import { createServer } from "node:net";
30803
+ function portFromEnvOrNull(variable) {
30795
30804
  const raw = process.env[variable]?.trim();
30796
30805
  if (!raw) {
30797
- return fallback;
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 ${portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
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
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
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
- "Ensure the Godot editor is running with its language server enabled, on port 6005 or on the port GDHARNESS_LSP_PORT names"
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.lspClient ??= new GodotLSPClient;
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.dapClient ??= new GodotDAPClient;
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: this.getEditorStatusPayload(),
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: await this.editorPlayingState(),
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, and a second one would take the language server and debug adapter ports from it.", [
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
- "Close the open editor first if the new project is the one you want"
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 editor = spawn(engine.value, editorArguments(project.value.path), {
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: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
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
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
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
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames: readPositiveNumber(args, "frames") ?? 1 }, patience);
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, "playing": playing, "scenePath": EditorInterface.get_playing_scene() if playing else ""
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: 4
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
- function portFromEnv(variable, fallback) {
24864
+ import { createServer } from "node:net";
24865
+ function portFromEnvOrNull(variable) {
24865
24866
  const raw = process.env[variable]?.trim();
24866
24867
  if (!raw) {
24867
- return fallback;
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 ${portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}. Start the Godot editor and enable Language Server in Editor Settings, or set GDHARNESS_LSP_PORT to the port it serves.`;
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
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
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
- "Ensure the Godot editor is running with its language server enabled, on port 6005 or on the port GDHARNESS_LSP_PORT names"
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.lspClient ??= new GodotLSPClient;
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.dapClient ??= new GodotDAPClient;
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: this.getEditorStatusPayload(),
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: await this.editorPlayingState(),
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, and a second one would take the language server and debug adapter ports from it.", [
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
- "Close the open editor first if the new project is the one you want"
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 editor = spawn(engine.value, editorArguments(project.value.path), {
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: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
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
- "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
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
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames: readPositiveNumber(args, "frames") ?? 1 }, patience);
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",
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",