gdharness 1.1.21 → 1.1.23

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.
Files changed (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -662,7 +662,7 @@ function environmentFor(options, variables = process.env) {
662
662
  const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
663
663
  return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
664
664
  }
665
- var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
665
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
666
666
 
667
667
  // src/godot-path.ts
668
668
  import { execFile as execFile2 } from "node:child_process";
@@ -7712,6 +7712,14 @@ function alongside(execPath, name) {
7712
7712
  }
7713
7713
  var init_runner = () => {};
7714
7714
 
7715
+ // src/dictionary.ts
7716
+ function emptyRecord() {
7717
+ return Object.create(null);
7718
+ }
7719
+ function dictionary(entries) {
7720
+ return Object.assign(emptyRecord(), entries);
7721
+ }
7722
+
7715
7723
  // src/call-signal.ts
7716
7724
  import { AsyncLocalStorage } from "node:async_hooks";
7717
7725
  function withCallSignal(signal, answer) {
@@ -7725,14 +7733,6 @@ var init_call_signal = __esm(() => {
7725
7733
  current = new AsyncLocalStorage;
7726
7734
  });
7727
7735
 
7728
- // src/dictionary.ts
7729
- function emptyRecord() {
7730
- return Object.create(null);
7731
- }
7732
- function dictionary(entries) {
7733
- return Object.assign(emptyRecord(), entries);
7734
- }
7735
-
7736
7736
  // src/game-log.ts
7737
7737
  import { StringDecoder } from "node:string_decoder";
7738
7738
  function announced(line) {
@@ -7850,6 +7850,51 @@ var init_game_log = __esm(() => {
7850
7850
  };
7851
7851
  });
7852
7852
 
7853
+ // src/engine-run.ts
7854
+ import { spawn as spawn2 } from "node:child_process";
7855
+ function runEngine(executable, args, options = {}) {
7856
+ return new Promise((resolve) => {
7857
+ const log = new GameLog;
7858
+ let failure = null;
7859
+ const signal = callSignal();
7860
+ const child = spawn2(executable, [...args], {
7861
+ stdio: ["ignore", "pipe", "pipe"],
7862
+ windowsHide: true,
7863
+ ...signal === undefined ? {} : { signal }
7864
+ });
7865
+ child.stdout.on("data", (chunk) => {
7866
+ log.append("stdout", chunk);
7867
+ });
7868
+ child.stderr.on("data", (chunk) => {
7869
+ log.append("stderr", chunk);
7870
+ });
7871
+ const { timeout } = options;
7872
+ const timer = timeout === undefined ? undefined : setTimeout(() => {
7873
+ failure ??= `it ran past its ${timeout.said}`;
7874
+ child.kill();
7875
+ }, timeout.ms);
7876
+ child.on("error", (error) => {
7877
+ failure ??= error.name === "AbortError" ? "the call was cancelled" : `it could not be started: ${error.message}`;
7878
+ });
7879
+ child.on("close", (code, closedBy) => {
7880
+ clearTimeout(timer);
7881
+ log.finish();
7882
+ const started = child.pid !== undefined;
7883
+ resolve({ log, exitCode: started ? code : null, exitSignal: closedBy, failure });
7884
+ });
7885
+ });
7886
+ }
7887
+ function howItEnded(run) {
7888
+ if (run.failure !== null) {
7889
+ return run.exitSignal === null ? run.failure : `ended by ${run.exitSignal}, because ${run.failure}`;
7890
+ }
7891
+ return run.exitSignal === null ? `exit code ${run.exitCode ?? "unknown"}` : `ended by ${run.exitSignal}`;
7892
+ }
7893
+ var init_engine_run = __esm(() => {
7894
+ init_call_signal();
7895
+ init_game_log();
7896
+ });
7897
+
7853
7898
  // src/reimport.ts
7854
7899
  import { createHash } from "node:crypto";
7855
7900
  import { existsSync as existsSync5, readFileSync as readFileSync3, rmSync as rmSync3, statSync as statSync2, utimesSync, writeFileSync as writeFileSync3 } from "node:fs";
@@ -7993,10 +8038,8 @@ var init_scratch = __esm(() => {
7993
8038
  });
7994
8039
 
7995
8040
  // src/headless.ts
7996
- import { execFile as execFile3 } from "node:child_process";
7997
- import { writeFileSync as writeFileSync4 } from "node:fs";
7998
- import { join as join7 } from "node:path";
7999
- import { promisify as promisify3 } from "node:util";
8041
+ import { existsSync as existsSync7, readFileSync as readFileSync4, realpathSync, writeFileSync as writeFileSync4 } from "node:fs";
8042
+ import { dirname as dirname4, join as join7 } from "node:path";
8000
8043
  function snakeCased(params) {
8001
8044
  const result = emptyRecord();
8002
8045
  for (const [key, value] of Object.entries(params)) {
@@ -8005,34 +8048,55 @@ function snakeCased(params) {
8005
8048
  }
8006
8049
  return result;
8007
8050
  }
8008
- function lastJsonObject(stdout) {
8009
- const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("{"));
8010
- for (const line of lines.reverse()) {
8011
- try {
8012
- const parsed = JSON.parse(line);
8013
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
8014
- return parsed;
8015
- }
8016
- } catch {}
8017
- }
8018
- return null;
8051
+ function engineExtras(outcome) {
8052
+ return {
8053
+ ...outcome.messages.length > 0 ? { engine_messages: outcome.messages } : {},
8054
+ ...outcome.messagesOmitted === undefined ? {} : { engine_messages_omitted: outcome.messagesOmitted },
8055
+ ...outcome.afterAnswer === undefined ? {} : { engine_after_answer: outcome.afterAnswer }
8056
+ };
8019
8057
  }
8020
- function problems(stderr) {
8021
- const log = new GameLog;
8022
- log.append("stderr", stderr);
8023
- log.finish();
8024
- return log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
8058
+ function problemsIn(log) {
8059
+ return log.everything().flatMap((entry) => {
8060
+ if (entry.severity !== "info") {
8061
+ return [entry];
8062
+ }
8063
+ if (entry.source === "stderr" && entry.text.startsWith(OWN_ERROR)) {
8064
+ return [{ ...entry, severity: "error", text: entry.text.slice(OWN_ERROR.length) }];
8065
+ }
8066
+ return [];
8067
+ });
8025
8068
  }
8026
- function reason(stdout, stderr) {
8027
- const own = stderr.split(/\r?\n/).filter((line) => line.startsWith("[ERROR] ")).map((line) => line.slice("[ERROR] ".length));
8069
+ function reported(problems) {
8070
+ const omitted = Math.max(0, problems.length - MESSAGES_KEPT);
8071
+ return omitted === 0 ? { messages: problems } : { messages: problems.slice(omitted), messagesOmitted: omitted };
8072
+ }
8073
+ function reason(log, problems) {
8074
+ const own = log.everything().filter((entry) => entry.source === "stderr" && entry.severity === "info" && entry.text.startsWith(OWN_ERROR));
8028
8075
  if (own.length > 0) {
8029
- return own.join("; ");
8076
+ return own.map((entry) => entry.text.slice(OWN_ERROR.length)).join("; ");
8077
+ }
8078
+ const errors = problems.filter((entry) => entry.severity === "error").map((entry) => entry.text);
8079
+ if (errors.length > 0) {
8080
+ const rest = errors.length - ERRORS_NAMED;
8081
+ return rest > 0 ? `${errors.slice(0, ERRORS_NAMED).join("; ")}; and ${rest} more error${rest === 1 ? "" : "s"}` : errors.join("; ");
8030
8082
  }
8031
- const engine = problems(stderr).filter((entry) => entry.severity === "error").map((entry) => entry.text);
8032
- if (engine.length > 0) {
8033
- return engine.join("; ");
8083
+ const printed = log.everything().filter((entry) => entry.source === "stdout");
8084
+ return printed.at(-1)?.text ?? "no output at all";
8085
+ }
8086
+ function scriptFault(problems, script) {
8087
+ const spelled = (path) => `(${path.replaceAll("\\", "/").toLowerCase()}/`;
8088
+ const directories = [...new Set([spelled(dirname4(script)), spelled(resolvedOr(dirname4(script)))])];
8089
+ return problems.find((entry) => {
8090
+ const at = entry.detail.find((line) => line.startsWith("at: "))?.replaceAll("\\", "/").toLowerCase();
8091
+ return entry.severity === "error" && at !== undefined && directories.some((directory) => at.includes(directory));
8092
+ });
8093
+ }
8094
+ function resolvedOr(path) {
8095
+ try {
8096
+ return realpathSync.native(path);
8097
+ } catch {
8098
+ return path;
8034
8099
  }
8035
- return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
8036
8100
  }
8037
8101
  function librariesNotCopied(messages) {
8038
8102
  const found = new Set;
@@ -8072,30 +8136,33 @@ async function runImport(godotPath, projectPath, options = {}) {
8072
8136
  }
8073
8137
  async function importOnce(godotPath, projectPath) {
8074
8138
  const logDir = scratchDirectory("import");
8139
+ let ran;
8075
8140
  try {
8076
- const { stderr } = await run3(godotPath, ["--headless", "--log-file", join7(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
8077
- return { ok: true, messages: problems(stderr) };
8078
- } catch (error) {
8079
- if (error instanceof Error && "stdout" in error && "stderr" in error) {
8080
- const failed = error;
8081
- return {
8082
- ok: false,
8083
- message: `the import pass failed (exit ${failed.code ?? "unknown"}): ${reason(failed.stdout, failed.stderr)}`,
8084
- messages: problems(failed.stderr)
8085
- };
8086
- }
8087
- return {
8088
- ok: false,
8089
- message: `the import pass could not be run: ${error instanceof Error ? error.message : String(error)}`,
8090
- messages: []
8091
- };
8141
+ ran = await runEngine(godotPath, [
8142
+ "--headless",
8143
+ "--log-file",
8144
+ join7(logDir, "engine.log"),
8145
+ "--path",
8146
+ projectPath,
8147
+ "--import"
8148
+ ]);
8092
8149
  } finally {
8093
8150
  discard(logDir);
8094
8151
  }
8152
+ const problems = problemsIn(ran.log);
8153
+ if (ran.exitCode === 0 && ran.failure === null) {
8154
+ return { ok: true, ...reported(problems) };
8155
+ }
8156
+ return {
8157
+ ok: false,
8158
+ message: `the import pass failed (${howItEnded(ran)}): ${reason(ran.log, problems)}`,
8159
+ ...reported(problems)
8160
+ };
8095
8161
  }
8096
8162
  async function runOperation(engine, operation, params, projectPath) {
8097
8163
  const paramsDir = scratchDirectory("params");
8098
8164
  const paramsFile = join7(paramsDir, `${operation}.json`);
8165
+ const answerFile = join7(paramsDir, "answer.json");
8099
8166
  writeFileSync4(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
8100
8167
  const args = [
8101
8168
  "--headless",
@@ -8107,50 +8174,73 @@ async function runOperation(engine, operation, params, projectPath) {
8107
8174
  engine.script,
8108
8175
  operation,
8109
8176
  `@file:${paramsFile}`,
8177
+ answerFile,
8110
8178
  ...engine.debug ? ["--debug-godot"] : []
8111
8179
  ];
8112
- let stdout;
8113
- let stderr;
8180
+ let ran;
8181
+ let answer;
8114
8182
  try {
8115
- ({ stdout, stderr } = await run3(engine.godotPath, args, { signal: callSignal() }));
8116
- } catch (error) {
8117
- if (error instanceof Error && "stdout" in error && "stderr" in error) {
8118
- const failed = error;
8119
- return {
8120
- ok: false,
8121
- message: `${operation} failed (exit ${failed.code ?? "unknown"}): ${reason(failed.stdout, failed.stderr)}`,
8122
- messages: problems(failed.stderr)
8123
- };
8124
- }
8183
+ ran = await runEngine(engine.godotPath, args);
8184
+ answer = answerIn(answerFile);
8185
+ } finally {
8186
+ discard(paramsDir);
8187
+ }
8188
+ const problems = problemsIn(ran.log);
8189
+ if (answer.kind === "unreadable") {
8125
8190
  return {
8126
8191
  ok: false,
8127
- message: `${operation} could not be run: ${error instanceof Error ? error.message : String(error)}`,
8128
- messages: []
8192
+ message: `${operation} wrote an answer that could not be read (${answer.why})`,
8193
+ ...reported(problems)
8194
+ };
8195
+ }
8196
+ if (answer.kind === "none") {
8197
+ const ending = ran.exitCode === 0 && ran.failure === null ? "produced no result" : `failed (${howItEnded(ran)})`;
8198
+ return {
8199
+ ok: false,
8200
+ message: `${operation} ${ending}: ${reason(ran.log, problems)}`,
8201
+ ...reported(problems)
8129
8202
  };
8130
- } finally {
8131
- discard(paramsDir);
8132
8203
  }
8133
- const payload = lastJsonObject(stdout);
8134
- if (payload === null) {
8204
+ const fault = scriptFault(problems, engine.script);
8205
+ if (fault !== undefined) {
8135
8206
  return {
8136
8207
  ok: false,
8137
- message: `${operation} produced no result: ${reason(stdout, stderr)}`,
8138
- messages: problems(stderr)
8208
+ message: `${operation} hit an error in the operations script, so its answer is left out: part of it was ` + `never computed. ${fault.text} (${fault.detail[0]?.slice("at: ".length) ?? "no location given"})`,
8209
+ ...reported(problems)
8210
+ };
8211
+ }
8212
+ const afterAnswer = ran.exitCode === 0 && ran.failure === null ? undefined : `The operation finished and wrote this answer, and then the engine did not exit cleanly (${howItEnded(ran)}). Anything it reported on the way out is under engine_messages.`;
8213
+ return {
8214
+ ok: true,
8215
+ payload: answer.payload,
8216
+ ...reported(problems),
8217
+ ...afterAnswer === undefined ? {} : { afterAnswer }
8218
+ };
8219
+ }
8220
+ function answerIn(file) {
8221
+ if (!existsSync7(file)) {
8222
+ return { kind: "none" };
8223
+ }
8224
+ const text = readFileSync4(file, "utf8");
8225
+ try {
8226
+ const parsed = JSON.parse(text);
8227
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? { kind: "answer", payload: parsed } : { kind: "unreadable", why: `it is JSON but not an object: ${text.slice(0, 200)}` };
8228
+ } catch (error) {
8229
+ return {
8230
+ kind: "unreadable",
8231
+ why: `${error instanceof Error ? error.message : String(error)}: ${text.slice(0, 200)}`
8139
8232
  };
8140
8233
  }
8141
- return { ok: true, payload, messages: problems(stderr) };
8142
8234
  }
8143
- var run3;
8235
+ var OWN_ERROR = "[ERROR] ", MESSAGES_KEPT = 200, ERRORS_NAMED = 3;
8144
8236
  var init_headless = __esm(() => {
8145
- init_call_signal();
8146
- init_game_log();
8237
+ init_engine_run();
8147
8238
  init_reimport();
8148
8239
  init_scratch();
8149
- run3 = promisify3(execFile3);
8150
8240
  });
8151
8241
 
8152
8242
  // src/update-check.ts
8153
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
8243
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
8154
8244
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
8155
8245
  import { join as join8 } from "node:path";
8156
8246
  function packageUrl(root) {
@@ -8192,10 +8282,10 @@ function cacheFile(environment = process.env) {
8192
8282
  }
8193
8283
  function readCache(path) {
8194
8284
  try {
8195
- if (!existsSync7(path)) {
8285
+ if (!existsSync8(path)) {
8196
8286
  return null;
8197
8287
  }
8198
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
8288
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
8199
8289
  if (typeof parsed !== "object" || parsed === null) {
8200
8290
  return null;
8201
8291
  }
@@ -8343,7 +8433,7 @@ var init_update_check = __esm(() => {
8343
8433
  });
8344
8434
 
8345
8435
  // src/server-version.ts
8346
- import { readFileSync as readFileSync5 } from "node:fs";
8436
+ import { readFileSync as readFileSync6 } from "node:fs";
8347
8437
  function editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest) {
8348
8438
  if (addonDigest !== undefined && addonDigest !== "" && shippedDigest !== undefined) {
8349
8439
  return addonDigest !== shippedDigest;
@@ -8356,23 +8446,24 @@ function sameCodeNote(addonVersion, serverVersion, addonDigest, shippedDigest) {
8356
8446
  }
8357
8447
  return `The editor loaded the ${addonVersion} addons, which are the same code this ${serverVersion} server ships, so nothing needs restarting; addonVersion changes at the next editor start.`;
8358
8448
  }
8359
- function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
8449
+ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8360
8450
  if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
8361
8451
  return;
8362
8452
  }
8363
8453
  const reported = addonVersion ?? "";
8454
+ const restart = onDisk !== undefined && onDisk !== shippedDigest ? `The project's own addons are not this server's either, so a restart would load the same code again: gdharness upgrade in the project installs the ${serverVersion} addons, and editor_launch restart then picks them up.` : "Restart it with editor_launch restart to pick this one up.";
8364
8455
  if (reported === serverVersion) {
8365
- return `The editor is running a different build of the ${reported} addon from the one this server ships. Restart it with editor_launch restart to pick this one up.`;
8456
+ return `The editor is running a different build of the ${reported} addon from the one this server ships. ${restart}`;
8366
8457
  }
8367
8458
  const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
8368
8459
  const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
8369
8460
  if (reported !== "" && isNewer(reported, serverVersion)) {
8370
8461
  return `${both} This server is the older half: reconnect it in your harness so it spawns ${reported}.`;
8371
8462
  }
8372
- return `${both} Restart it with editor_launch restart to pick the new one up.`;
8463
+ return `${both} ${restart}`;
8373
8464
  }
8374
- function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest) {
8375
- const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest);
8465
+ function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8466
+ const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk);
8376
8467
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
8377
8468
  return answer;
8378
8469
  }
@@ -8385,7 +8476,7 @@ var init_server_version = __esm(() => {
8385
8476
  GODOT_DEBUG_MODE_DEFAULT = process.env["GODOT_DEBUG"] === "true" || DEBUG_MODE;
8386
8477
  SERVER_VERSION = (() => {
8387
8478
  try {
8388
- const pkg = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
8479
+ const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
8389
8480
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
8390
8481
  } catch {
8391
8482
  return "0.0.0";
@@ -8518,9 +8609,79 @@ var init_issues = __esm(() => {
8518
8609
  ENHANCEMENT_URL = `${NEW_ISSUE}?template=feature_request.md`;
8519
8610
  });
8520
8611
 
8612
+ // src/paths.ts
8613
+ import { realpathSync as realpathSync2 } from "node:fs";
8614
+ import { isAbsolute, relative, resolve, sep } from "node:path";
8615
+ function realPathOr(path) {
8616
+ try {
8617
+ return realpathSync2.native(path);
8618
+ } catch {
8619
+ return path;
8620
+ }
8621
+ }
8622
+ function isSameDirectory(onePath, otherPath) {
8623
+ return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
8624
+ }
8625
+ function isWithinRoot(rootPath, candidatePath) {
8626
+ const step = relative(resolve(rootPath), resolve(candidatePath));
8627
+ return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
8628
+ }
8629
+ function resolveWithinProject(projectPath, candidatePath) {
8630
+ const trimmed = candidatePath.trim();
8631
+ if (trimmed === "") {
8632
+ return { ok: false, reason: "Path is empty." };
8633
+ }
8634
+ if (trimmed.includes("\x00")) {
8635
+ return { ok: false, reason: "Path contains a null byte." };
8636
+ }
8637
+ const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
8638
+ if (withoutScheme.startsWith("uid://")) {
8639
+ return {
8640
+ ok: false,
8641
+ reason: `Path '${trimmed}' is a UID, and a file is judged here by where it is on disk, which a UID does not say. Give the file's res:// path.`
8642
+ };
8643
+ }
8644
+ const scheme = OTHER_SCHEME.exec(withoutScheme);
8645
+ if (scheme) {
8646
+ return {
8647
+ ok: false,
8648
+ reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
8649
+ };
8650
+ }
8651
+ if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
8652
+ return {
8653
+ ok: false,
8654
+ reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
8655
+ };
8656
+ }
8657
+ if (withoutScheme.includes(":")) {
8658
+ return {
8659
+ ok: false,
8660
+ reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
8661
+ };
8662
+ }
8663
+ const root = resolve(projectPath);
8664
+ const absolutePath = resolve(root, withoutScheme);
8665
+ if (!isWithinRoot(root, absolutePath)) {
8666
+ return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
8667
+ }
8668
+ const relativePath = relative(root, absolutePath).split(sep).join("/");
8669
+ if (relativePath === "") {
8670
+ return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
8671
+ }
8672
+ return { ok: true, absolutePath, relativePath };
8673
+ }
8674
+ var RESOURCE_SCHEME = "res://", OTHER_SCHEME;
8675
+ var init_paths = __esm(() => {
8676
+ OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
8677
+ });
8678
+
8521
8679
  // src/class-cache.ts
8522
- import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync3 } from "node:fs";
8680
+ import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync3 } from "node:fs";
8523
8681
  import { join as join9 } from "node:path";
8682
+ function isToolScript(source) {
8683
+ return TOOL.test(codeOf(source));
8684
+ }
8524
8685
  function declaredClasses(projectPath) {
8525
8686
  const declared = new Map;
8526
8687
  eachScript(projectPath, (script, source) => {
@@ -8533,7 +8694,7 @@ function declaredClasses(projectPath) {
8533
8694
  }
8534
8695
  function eachScript(projectPath, each) {
8535
8696
  const visit = (directory, prefix) => {
8536
- if (existsSync8(join9(directory, ".gdignore"))) {
8697
+ if (existsSync9(join9(directory, ".gdignore"))) {
8537
8698
  return;
8538
8699
  }
8539
8700
  for (const entry of readdirSync3(directory, { withFileTypes: true })) {
@@ -8544,7 +8705,7 @@ function eachScript(projectPath, each) {
8544
8705
  if (entry.isDirectory()) {
8545
8706
  visit(path, `${prefix}${entry.name}/`);
8546
8707
  } else if (entry.isFile() && entry.name.endsWith(".gd")) {
8547
- each(`res://${prefix}${entry.name}`, readFileSync6(path, "utf8"));
8708
+ each(`res://${prefix}${entry.name}`, readFileSync7(path, "utf8"));
8548
8709
  }
8549
8710
  }
8550
8711
  };
@@ -8560,7 +8721,7 @@ function scriptsNaming(projectPath, classes) {
8560
8721
  }
8561
8722
  const code = codeOf(source);
8562
8723
  if (classesNamedIn(code, named).length > 0) {
8563
- naming.push({ script, tool: /^\s*@tool\b/m.test(code) });
8724
+ naming.push({ script, tool: TOOL.test(code) });
8564
8725
  }
8565
8726
  });
8566
8727
  return naming.sort((a, b) => a.script.localeCompare(b.script));
@@ -8594,11 +8755,11 @@ function codeOf(source) {
8594
8755
  }
8595
8756
  function cachedClasses(projectPath) {
8596
8757
  const cache = join9(projectPath, ".godot", "global_script_class_cache.cfg");
8597
- if (!existsSync8(cache)) {
8758
+ if (!existsSync9(cache)) {
8598
8759
  return null;
8599
8760
  }
8600
8761
  const listed = new Map;
8601
- const text = readFileSync6(cache, "utf8");
8762
+ const text = readFileSync7(cache, "utf8");
8602
8763
  for (const entry of text.matchAll(/"class":\s*&"([^"]+)"[\s\S]*?"path":\s*"([^"]+)"/g)) {
8603
8764
  listed.set(entry[1] ?? "", entry[2] ?? "");
8604
8765
  }
@@ -8767,6 +8928,19 @@ function classRemedy(entries) {
8767
8928
  const oneAtATime = declaring.length === 1 ? "" : " Take them one at a time: a rescan that brings in a class also reloads every script naming it and lists them under dependentsReloaded, so a script listed there needs no call of its own.";
8768
8929
  return `For ${entries.map(written).join(", ")}, run ${reload} first.${oneAtATime} In the one project ` + "that has reproduced a stale enum member, three times, the reload cleared it at once each time, " + "and the one plain editor_rescan tried left the diagnostic standing.";
8769
8930
  }
8931
+ function notReloadedNote(broughtIn, notReloaded) {
8932
+ const tried = notReloaded.filter((one) => one.tool !== true).map((one) => one.scriptPath);
8933
+ const tools = notReloaded.filter((one) => one.tool === true).map((one) => one.scriptPath);
8934
+ const all = [...tried, ...tools];
8935
+ const remedies = [];
8936
+ if (tried.length > 0) {
8937
+ remedies.push(`For ${tried.join(", ")}: editor_rescan with reloadScript on ${tried.length === 1 ? "it" : "each"}, or editor_launch restart.`);
8938
+ }
8939
+ if (tools.length > 0) {
8940
+ remedies.push(`For ${tools.join(", ")}, ${tools.length === 1 ? "a @tool script" : "@tool scripts"}, which ${tools.length === 1 ? "runs" : "run"} inside the editor and ${tools.length === 1 ? "is" : "are"} not reloaded from inside a call: editor_launch restart.`);
8941
+ }
8942
+ return `The scan brought in ${broughtIn.join(", ")}, and ${all.join(", ")} ${all.length === 1 ? "names one of them and" : "name them and"} could not be reloaded, so the editor still holds what it compiled while it could not see the class and reports that to the language server. ${remedies.join(" ")}`;
8943
+ }
8770
8944
  function failedReloadNote(failed) {
8771
8945
  const missing = (before, after) => before.filter((name) => !after.includes(name));
8772
8946
  const lost = [
@@ -8775,7 +8949,17 @@ function failedReloadNote(failed) {
8775
8949
  ];
8776
8950
  const unchanged = lost.length === 0 && missing(failed.heldAfter, failed.heldBefore).length === 0 && missing(failed.constantsAfter, failed.constantsBefore).length === 0;
8777
8951
  const answered = failed.codeName === "" ? `error ${failed.code}` : `error ${failed.code}, ${failed.codeName}`;
8778
- const copy = unchanged ? "Nothing was reloaded: the copy the editor holds is as it was, with the members under heldBeforeReload and heldConstantsBeforeReload." : `The copy the editor holds changed even so${lost.length === 0 ? "" : `, and has lost ${lost.join(", ")}`}: anything diagnosed against it now is answered from what is left, and reloading it once it compiles rebuilds it.`;
8952
+ const unusable = "A failed compile leaves the copy the editor holds unusable, measured on 4.7.2: the editor can make no instance of it, and one it already made has lost its methods, while its member lists read as before.";
8953
+ let copy;
8954
+ if (failed.restored === null) {
8955
+ copy = `${unusable} This editor's addon does not put it back, so it stays that way until the script compiles: reload it then, or restart the editor with editor_launch restart.`;
8956
+ } else if (!failed.restored) {
8957
+ copy = `${unusable} The editor compiled it again from the text it was built from, and that failed too${failed.restoredAs === "" ? "" : ` (${failed.restoredAs})`}, so it stays that way until the script compiles: reload it then, or restart the editor with editor_launch restart.`;
8958
+ } else if (unchanged) {
8959
+ copy = "The copy the editor holds was compiled again from the text it was built from, which puts it back as it was, with the members under heldBeforeReload and heldConstantsBeforeReload.";
8960
+ } else {
8961
+ copy = `The copy the editor holds was compiled again from the text it was built from and changed even so${lost.length === 0 ? "" : `, and has lost ${lost.join(", ")}`}: anything diagnosed against it now is answered from what is left, and reloading it once it compiles rebuilds it.`;
8962
+ }
8779
8963
  const order = failed.names.length === 0 ? "" : ` If they deny a member of ${failed.names.join(" or ")} that its file declares, the editor's copy of that class is behind: reload that class first with reloadScript and then this script, the order that cleared it in the one project that has met this.`;
8780
8964
  return `${failed.script} did not compile: Godot answered ${answered}. ${copy} ` + `script_diagnostics on it gives the errors.${order}`;
8781
8965
  }
@@ -8808,7 +8992,7 @@ function declaredSince(projectPath, paths, names, since) {
8808
8992
  });
8809
8993
  }
8810
8994
  function cachedAtMissingPaths(cached, projectPath) {
8811
- return [...cached].filter(([, path]) => path.startsWith("res://") && !existsSync8(join9(projectPath, path.slice("res://".length)))).map(([name]) => name).sort();
8995
+ return [...cached].filter(([, path]) => path.startsWith("res://") && !existsSync9(join9(projectPath, path.slice("res://".length)))).map(([name]) => name).sort();
8812
8996
  }
8813
8997
  function staleAgainst(cached, projectPath) {
8814
8998
  return [...declaredClasses(projectPath)].filter(([name, path]) => cached.get(name) !== path).map(([name]) => name);
@@ -8825,9 +9009,10 @@ function unseenByEditor(projectPath, editorHolds) {
8825
9009
  const held = new Set(editorHolds);
8826
9010
  return [...declaredClasses(projectPath)].filter(([name]) => !held.has(name)).map(([className, path]) => ({ className, path })).sort((one, other) => one.className < other.className ? -1 : one.className > other.className ? 1 : 0);
8827
9011
  }
8828
- var DECLARATION;
9012
+ var DECLARATION, TOOL;
8829
9013
  var init_class_cache = __esm(() => {
8830
9014
  DECLARATION = /^(?:@[a-z_]+(?:\([^)\n]*\))?\s+)*class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m;
9015
+ TOOL = /^[ \t]*(?:@[a-z_]+(?:\([^)\n]*\))?[ \t]+)*@tool\b/m;
8831
9016
  });
8832
9017
 
8833
9018
  // node_modules/zod/v4/core/util.js
@@ -15933,74 +16118,13 @@ var init_types = __esm(() => {
15933
16118
  };
15934
16119
  });
15935
16120
 
15936
- // src/paths.ts
15937
- import { realpathSync } from "node:fs";
15938
- import { isAbsolute, relative, resolve, sep } from "node:path";
15939
- function realPathOr(path) {
15940
- try {
15941
- return realpathSync.native(path);
15942
- } catch {
15943
- return path;
15944
- }
15945
- }
15946
- function isSameDirectory(onePath, otherPath) {
15947
- return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
15948
- }
15949
- function isWithinRoot(rootPath, candidatePath) {
15950
- const step = relative(resolve(rootPath), resolve(candidatePath));
15951
- return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
15952
- }
15953
- function resolveWithinProject(projectPath, candidatePath) {
15954
- const trimmed = candidatePath.trim();
15955
- if (trimmed === "") {
15956
- return { ok: false, reason: "Path is empty." };
15957
- }
15958
- if (trimmed.includes("\x00")) {
15959
- return { ok: false, reason: "Path contains a null byte." };
15960
- }
15961
- const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
15962
- const scheme = OTHER_SCHEME.exec(withoutScheme);
15963
- if (scheme) {
15964
- return {
15965
- ok: false,
15966
- reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
15967
- };
15968
- }
15969
- if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
15970
- return {
15971
- ok: false,
15972
- reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
15973
- };
15974
- }
15975
- if (withoutScheme.includes(":")) {
15976
- return {
15977
- ok: false,
15978
- reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
15979
- };
15980
- }
15981
- const root = resolve(projectPath);
15982
- const absolutePath = resolve(root, withoutScheme);
15983
- if (!isWithinRoot(root, absolutePath)) {
15984
- return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
15985
- }
15986
- const relativePath = relative(root, absolutePath).split(sep).join("/");
15987
- if (relativePath === "") {
15988
- return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
15989
- }
15990
- return { ok: true, absolutePath, relativePath };
15991
- }
15992
- var RESOURCE_SCHEME = "res://", OTHER_SCHEME;
15993
- var init_paths = __esm(() => {
15994
- OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
15995
- });
15996
-
15997
16121
  // src/resources.ts
15998
- import { readFileSync as readFileSync7 } from "node:fs";
16122
+ import { readFileSync as readFileSync8, realpathSync as realpathSync3 } from "node:fs";
15999
16123
  import { extname, resolve as resolve2 } from "node:path";
16000
16124
  function ensureProjectPath(getProjectPath) {
16001
16125
  const projectPath = getProjectPath();
16002
16126
  if (!projectPath) {
16003
- throw new Refusal("Project path is not set. Set a Godot project path first.");
16127
+ throw new Refusal("This server knows no project to read from: start it with GDHARNESS_PROJECT, connect an editor, or make any tool call naming a projectPath that holds project.godot.");
16004
16128
  }
16005
16129
  return resolve2(projectPath);
16006
16130
  }
@@ -16016,7 +16140,22 @@ function resolveProjectFile(projectPath, resourcePath) {
16016
16140
  if (!contained.ok) {
16017
16141
  throw new Refusal(contained.reason);
16018
16142
  }
16019
- return contained.absolutePath;
16143
+ let root;
16144
+ try {
16145
+ root = realpathSync3.native(projectPath);
16146
+ } catch {
16147
+ throw new Refusal(`The project directory ${projectPath} is not there.`);
16148
+ }
16149
+ let real;
16150
+ try {
16151
+ real = realpathSync3.native(contained.absolutePath);
16152
+ } catch {
16153
+ throw new Refusal(`No file at ${resourcePath} in the project.`);
16154
+ }
16155
+ if (!isWithinRoot(root, real)) {
16156
+ throw new Refusal(`${resourcePath} leads outside the project through a link, so it is not read.`);
16157
+ }
16158
+ return real;
16020
16159
  }
16021
16160
  function parseGodotUri(uri) {
16022
16161
  if (uri === "godot://project/info") {
@@ -16161,7 +16300,7 @@ function readResourceText(uri, getProjectPath) {
16161
16300
  const parsedUri = parseGodotUri(uri);
16162
16301
  if (parsedUri.kind === "project-info") {
16163
16302
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
16164
- const rawProject = readFileSync7(projectFilePath, "utf-8");
16303
+ const rawProject = readFileSync8(projectFilePath, "utf-8");
16165
16304
  const parsedProject = parseProjectGodot(rawProject);
16166
16305
  return {
16167
16306
  mimeType: "application/json",
@@ -16170,7 +16309,7 @@ function readResourceText(uri, getProjectPath) {
16170
16309
  }
16171
16310
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
16172
16311
  ensureAllowedExtension(parsedUri.kind, filePath);
16173
- const text = readFileSync7(filePath, "utf-8");
16312
+ const text = readFileSync8(filePath, "utf-8");
16174
16313
  return {
16175
16314
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
16176
16315
  text
@@ -16302,16 +16441,16 @@ import { spawnSync } from "node:child_process";
16302
16441
  import { createHash as createHash3 } from "node:crypto";
16303
16442
  import {
16304
16443
  cpSync,
16305
- existsSync as existsSync9,
16444
+ existsSync as existsSync10,
16306
16445
  mkdirSync as mkdirSync4,
16307
16446
  readdirSync as readdirSync4,
16308
- readFileSync as readFileSync8,
16447
+ readFileSync as readFileSync9,
16309
16448
  rmdirSync,
16310
16449
  rmSync as rmSync5,
16311
16450
  statSync as statSync4,
16312
16451
  writeFileSync as writeFileSync6
16313
16452
  } from "node:fs";
16314
- import { dirname as dirname4, join as join10 } from "node:path";
16453
+ import { dirname as dirname5, join as join10 } from "node:path";
16315
16454
  import { fileURLToPath } from "node:url";
16316
16455
  function editorAddonDigest(from = shippedAddonsDirectory()) {
16317
16456
  const hash = createHash3("sha256");
@@ -16327,7 +16466,7 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
16327
16466
  } else {
16328
16467
  hash.update(`${inside}
16329
16468
  `);
16330
- hash.update(readFileSync8(path));
16469
+ hash.update(readFileSync9(path));
16331
16470
  hash.update(`
16332
16471
  `);
16333
16472
  }
@@ -16350,29 +16489,29 @@ function shippedEditorDigest() {
16350
16489
  }
16351
16490
  function installedEditorDigest(projectPath) {
16352
16491
  const marker = join10(projectPath, "addons", ADDONS[0], DIGEST_MARKER);
16353
- if (!existsSync9(marker)) {
16492
+ if (!existsSync10(marker)) {
16354
16493
  return null;
16355
16494
  }
16356
- const said = readFileSync8(marker, "utf8").trim();
16495
+ const said = readFileSync9(marker, "utf8").trim();
16357
16496
  return said === "" ? null : said;
16358
16497
  }
16359
16498
  function shippedAddonsDirectory() {
16360
- return join10(dirname4(fileURLToPath(import.meta.url)), "godot", "addons");
16499
+ return join10(dirname5(fileURLToPath(import.meta.url)), "godot", "addons");
16361
16500
  }
16362
16501
  function shippedOperationsScript() {
16363
- return join10(dirname4(fileURLToPath(import.meta.url)), "godot", "operations", "godot_operations.gd");
16502
+ return join10(dirname5(fileURLToPath(import.meta.url)), "godot", "operations", "godot_operations.gd");
16364
16503
  }
16365
16504
  function installAddons(projectPath, from = shippedAddonsDirectory()) {
16366
16505
  const installed = [];
16367
16506
  for (const name of ADDONS) {
16368
16507
  const source = join10(from, name);
16369
- if (!existsSync9(join10(source, name === "gdharness_runtime" ? "runtime_autoload.gd" : "plugin.cfg"))) {
16508
+ if (!existsSync10(join10(source, name === "gdharness_runtime" ? "runtime_autoload.gd" : "plugin.cfg"))) {
16370
16509
  throw new Error(`The package holds no ${name} addon at ${source}.`);
16371
16510
  }
16372
16511
  const target = join10(projectPath, "addons", name);
16373
- const replaced = existsSync9(target);
16512
+ const replaced = existsSync10(target);
16374
16513
  rmSync5(target, { recursive: true, force: true });
16375
- mkdirSync4(dirname4(target), { recursive: true });
16514
+ mkdirSync4(dirname5(target), { recursive: true });
16376
16515
  cpSync(source, target, { recursive: true });
16377
16516
  writeFileSync6(join10(target, VERSION_MARKER), `${SERVER_VERSION}
16378
16517
  `);
@@ -16384,23 +16523,23 @@ function installAddons(projectPath, from = shippedAddonsDirectory()) {
16384
16523
  }
16385
16524
  function installedAddonVersion(projectPath) {
16386
16525
  const marker = join10(projectPath, "addons", ADDONS[0], VERSION_MARKER);
16387
- if (!existsSync9(marker)) {
16526
+ if (!existsSync10(marker)) {
16388
16527
  return null;
16389
16528
  }
16390
- const said = readFileSync8(marker, "utf8").trim();
16529
+ const said = readFileSync9(marker, "utf8").trim();
16391
16530
  return said === "" ? null : said;
16392
16531
  }
16393
16532
  function removeAddons(projectPath) {
16394
16533
  const removed = [];
16395
16534
  for (const name of ADDONS) {
16396
16535
  const target = join10(projectPath, "addons", name);
16397
- if (existsSync9(target)) {
16536
+ if (existsSync10(target)) {
16398
16537
  rmSync5(target, { recursive: true, force: true });
16399
16538
  removed.push(target);
16400
16539
  }
16401
16540
  }
16402
16541
  const addons = join10(projectPath, "addons");
16403
- if (removed.length > 0 && existsSync9(addons) && readdirSync4(addons).length === 0) {
16542
+ if (removed.length > 0 && existsSync10(addons) && readdirSync4(addons).length === 0) {
16404
16543
  rmdirSync(addons);
16405
16544
  removed.push(addons);
16406
16545
  }
@@ -16424,7 +16563,7 @@ function autoloadIsOurs(named) {
16424
16563
  return named === null || named === `res://${RUNTIME_AUTOLOAD.path}`;
16425
16564
  }
16426
16565
  async function setRuntime(engine, projectPath, on) {
16427
- return on ? await runOperation(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, enabled: true }, projectPath) : await runOperation(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
16566
+ return on ? await runOperation(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, global: true }, projectPath) : await runOperation(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
16428
16567
  }
16429
16568
  function loaderAmong(projectPath, named) {
16430
16569
  let byName = null;
@@ -16434,7 +16573,7 @@ function loaderAmong(projectPath, named) {
16434
16573
  }
16435
16574
  let source = null;
16436
16575
  try {
16437
- source = readFileSync8(join10(projectPath, path), "utf8");
16576
+ source = readFileSync9(join10(projectPath, path), "utf8");
16438
16577
  } catch {}
16439
16578
  if (source?.includes(RUNTIME_AUTOLOAD.path)) {
16440
16579
  return { name: entry, path, exists: true };
@@ -16478,8 +16617,8 @@ function inspectProject(projectPath) {
16478
16617
  const addons = ADDONS.map((name) => {
16479
16618
  const directory = join10(projectPath, "addons", name);
16480
16619
  const marker = join10(directory, VERSION_MARKER);
16481
- const installed = existsSync9(directory);
16482
- const version = existsSync9(marker) ? readFileSync8(marker, "utf8").trim() : null;
16620
+ const installed = existsSync10(directory);
16621
+ const version = existsSync10(marker) ? readFileSync9(marker, "utf8").trim() : null;
16483
16622
  const current = version === SERVER_VERSION;
16484
16623
  if (!installed) {
16485
16624
  problems.push(`addons/${name} is not installed; run gdharness setup`);
@@ -16488,7 +16627,7 @@ function inspectProject(projectPath) {
16488
16627
  }
16489
16628
  return { name, installed, version, current };
16490
16629
  });
16491
- const settings = parseProjectGodot(readFileSync8(join10(projectPath, "project.godot"), "utf8"));
16630
+ const settings = parseProjectGodot(readFileSync9(join10(projectPath, "project.godot"), "utf8"));
16492
16631
  const pluginsEnabled = enabledPlugins(settings);
16493
16632
  for (const name of EDITOR_PLUGINS) {
16494
16633
  if (!pluginsEnabled.includes(name)) {
@@ -16504,7 +16643,7 @@ function inspectProject(projectPath) {
16504
16643
  const tracked = trackedByGit(projectPath, [...named.values()]);
16505
16644
  const addonMissing = (path) => addons.some((addon) => !addon.installed && path.startsWith(`addons/${addon.name}/`));
16506
16645
  for (const [name, path] of named) {
16507
- if (!existsSync9(join10(projectPath, path))) {
16646
+ if (!existsSync10(join10(projectPath, path))) {
16508
16647
  if (!addonMissing(path)) {
16509
16648
  problems.push(`the ${name} autoload names res://${path}, which is not in this project, so it boots with a missing script; restore that file, or point the autoload at one that is here`);
16510
16649
  }
@@ -16664,7 +16803,7 @@ var init_tool_definitions = __esm(() => {
16664
16803
  };
16665
16804
  PROPERTIES = {
16666
16805
  type: "object",
16667
- description: 'Properties to set, keyed by Godot property name. Vectors, colours and the like may be written as {"x": 1, "y": 2} or tagged {"_type": "Vector2", "x": 1, "y": 2}, which is the form a read answers with, so a value read off one node can be written straight to another.',
16806
+ description: 'Properties to set, keyed by Godot property name. Vectors, colours and the like may be written as {"x": 1, "y": 2} or tagged {"_type": "Vector2", "x": 1, "y": 2}, which is the form a read answers with, so a value read off one node can be written straight to another. A property holding a node takes the path to it from the node being set, such as "../Player". Each value is read back once set, and one the engine changed or refused is refused with what it holds.',
16668
16807
  additionalProperties: true
16669
16808
  };
16670
16809
  XY = {
@@ -16819,11 +16958,18 @@ var init_tool_definitions = __esm(() => {
16819
16958
  },
16820
16959
  value: {
16821
16960
  blank: true,
16822
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
16961
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}, and infinity as {"_type": "float", "value": "inf"}, the form a read answers with. "" writes an empty string.'
16823
16962
  },
16824
16963
  name: { type: "string", description: "Autoload name." },
16825
16964
  path: { type: "string", description: "Autoload script or scene inside the project." },
16826
- enabled: { type: "boolean", description: "Autoloads: register enabled. Default true." },
16965
+ enabled: {
16966
+ type: "boolean",
16967
+ description: "Autoloads: Godot 4 loads every autoload in the list, so false is refused. Use global: false to keep the name out of the global scope, or remove_autoload to stop it loading."
16968
+ },
16969
+ global: {
16970
+ type: "boolean",
16971
+ description: `Autoloads: make the name a global variable, the * in project.godot and the editor's "Global Variable" column. Default true. The autoload loads either way.`
16972
+ },
16827
16973
  scenePath: SCENE_PATH,
16828
16974
  actionName: { type: "string", description: 'Input action name, such as "jump".' },
16829
16975
  events: INPUT_EVENTS,
@@ -16835,9 +16981,15 @@ var init_tool_definitions = __esm(() => {
16835
16981
  },
16836
16982
  pluginName: { type: "string", description: "Folder name under addons/." },
16837
16983
  busName: { type: "string", description: "Audio bus name." },
16838
- parentBusIndex: { type: "number", description: "Audio buses: the bus to send to. Default 0, Master." },
16984
+ parentBusIndex: {
16985
+ type: "number",
16986
+ description: "Audio buses: the bus to send to. Default 0, Master. The new bus goes right after it, so every later bus moves one along, and the answer lists them all."
16987
+ },
16839
16988
  busIndex: { type: "number", description: "Audio bus index." },
16840
- effectIndex: { type: "number", description: "Slot on the bus for the effect." },
16989
+ effectIndex: {
16990
+ type: "number",
16991
+ description: "Slot on the bus for the effect: the effect there is replaced, and one past the last adds one. Any other slot is refused."
16992
+ },
16841
16993
  effectType: { type: "string", description: 'Effect class, such as "AudioEffectReverb".' },
16842
16994
  volumeDb: { type: "number", description: "Bus volume in decibels." }
16843
16995
  },
@@ -16856,7 +17008,7 @@ var init_tool_definitions = __esm(() => {
16856
17008
  disable_plugin: { summary: "disable an addon", requires: ["pluginName"] },
16857
17009
  add_audio_bus: { summary: "add an audio bus", requires: ["busName"] },
16858
17010
  set_audio_bus_effect: {
16859
- summary: "add or configure an effect on a bus",
17011
+ summary: "put an effect in a slot on a bus",
16860
17012
  requires: ["busIndex", "effectIndex", "effectType"]
16861
17013
  },
16862
17014
  set_audio_bus_volume: { summary: "set a bus volume", requires: ["busIndex", "volumeDb"] }
@@ -16895,7 +17047,11 @@ var init_tool_definitions = __esm(() => {
16895
17047
  type: "boolean",
16896
17048
  description: "forward: include the engine's own res://. resources. Default false."
16897
17049
  },
16898
- fileTypes: { type: "array", items: { type: "string" }, description: "reverse: extensions to look in." }
17050
+ fileTypes: {
17051
+ type: "array",
17052
+ items: { type: "string" },
17053
+ description: 'reverse: extensions to look in, plus "godot" for project.godot, "cfg" for plugin.cfg and export_presets.cfg, and "import" for import sidecars. Default all of tscn, tres, gd, gdshader, godot, cfg and import, since an autoload, the main scene and a plugin script are named only in the settings files.'
17054
+ }
16899
17055
  },
16900
17056
  requires: ["projectPath", "resourcePath"]
16901
17057
  },
@@ -16940,7 +17096,10 @@ var init_tool_definitions = __esm(() => {
16940
17096
  summary: "reimport what status says is outdated or failed, one resource with resourcePath, or what is current as well with force. Through the open editor when one serves the project, so it reloads what it holds, waiting for it to finish; otherwise through the engine's own import pass. Answered from a second status read: reimported lists what is current now, notReimported what is not, with its status and reason, and via says which of the two did the work. stillImporting is an editor that had not finished within timeoutMs",
16941
17097
  requires: []
16942
17098
  },
16943
- uid: { summary: "the UID of one file", requires: ["resourcePath"] },
17099
+ uid: {
17100
+ summary: "the UID of one file and where it was read: a script's or shader's .uid file, an imported file's .import, or a scene's or resource's own header",
17101
+ requires: ["resourcePath"]
17102
+ },
16944
17103
  refresh_uids: {
16945
17104
  summary: "import the project so every script and shader has its .uid sidecar, and name under uidsCreated the ones this made and under stillWithoutUid the ones the engine would not import: it writes no scene and no script, so a project whose sidecars are all present is left untouched",
16946
17105
  requires: []
@@ -17001,11 +17160,11 @@ var init_tool_definitions = __esm(() => {
17001
17160
  },
17002
17161
  {
17003
17162
  name: "scene_create",
17004
- description: "Creates a scene file, saves one, or saves a copy under a new path. Needs the editor connected.",
17163
+ description: "Creates a scene file where none is, saves one, or saves a copy under a new path. A save of a scene open in the editor saves the editor's copy, unsaved changes included. Needs the editor connected.",
17005
17164
  parameters: {
17006
17165
  projectPath: PROJECT_PATH,
17007
17166
  scenePath: SCENE_PATH,
17008
- rootNodeType: { type: "string", description: "create: the root node class. Default Node2D." },
17167
+ rootNodeType: { type: "string", description: "create: the root node class. Default Node." },
17009
17168
  newPath: { type: "string", description: "save_as: where the copy goes." }
17010
17169
  },
17011
17170
  requires: ["projectPath", "scenePath"],
@@ -17029,7 +17188,7 @@ var init_tool_definitions = __esm(() => {
17029
17188
  },
17030
17189
  {
17031
17190
  name: "scene_node",
17032
- description: "One node in a scene file: add, read, set, duplicate, reparent or delete it, or paint TileMap cells. Any ClassDB node type can be added, so a NavigationRegion2D, an AnimationTree or a Camera3D is an add with that nodeType and its properties. A property holding a Resource takes the res:// path of one, so a texture, a material or a theme is a set like any other. Needs the editor connected.",
17191
+ description: "One node in a scene file: add, read, set, duplicate, reparent or delete it, or paint TileMap cells. Any ClassDB node type can be added, so a NavigationRegion2D, an AnimationTree or a Camera3D is an add with that nodeType and its properties. A property holding a Resource takes the res:// path of one, so a texture, a material or a theme is a set like any other. A write is refused while the editor holds unsaved changes to the scene, and for a node that belongs to a scene this one instances or inherits. Needs the editor connected.",
17033
17192
  parameters: {
17034
17193
  projectPath: PROJECT_PATH,
17035
17194
  scenePath: SCENE_PATH,
@@ -17047,7 +17206,10 @@ var init_tool_definitions = __esm(() => {
17047
17206
  type: "boolean",
17048
17207
  description: "get: include properties still at their default. Default false."
17049
17208
  },
17050
- layer: { type: "number", description: "set_tilemap_cells: the TileMap layer. Default 0." },
17209
+ layer: {
17210
+ type: "number",
17211
+ description: "set_tilemap_cells: the layer of a TileMap. Default 0. A TileMapLayer is one layer."
17212
+ },
17051
17213
  cells: TILEMAP_CELLS
17052
17214
  },
17053
17215
  requires: ["projectPath", "scenePath"],
@@ -17058,7 +17220,10 @@ var init_tool_definitions = __esm(() => {
17058
17220
  duplicate: { summary: "copy a node and its children", requires: ["nodePath", "newName"] },
17059
17221
  reparent: { summary: "move a node under another parent", requires: ["nodePath", "newParentPath"] },
17060
17222
  delete: { summary: "remove a node and its children", requires: ["nodePath"] },
17061
- set_tilemap_cells: { summary: "place tiles in a TileMap", requires: ["nodePath", "cells"] }
17223
+ set_tilemap_cells: {
17224
+ summary: "place tiles in a TileMapLayer or TileMap",
17225
+ requires: ["nodePath", "cells"]
17226
+ }
17062
17227
  }
17063
17228
  },
17064
17229
  {
@@ -17146,13 +17311,16 @@ var init_tool_definitions = __esm(() => {
17146
17311
  },
17147
17312
  {
17148
17313
  name: "script_edit",
17149
- description: "Creates a GDScript file, or adds functions, variables and signals to one. Every declaration written carries a type. create loads what it wrote under the project's own warning settings and answers with parses; the engine's reasons for a refusal come back under engine_messages.",
17314
+ description: "Creates a GDScript file, or adds functions, variables and signals to one. Every declaration written carries a type. Both load what they wrote under the project's own warning settings and answer with parses; the engine's reasons for a refusal come back under engine_messages. create answers with the extends and class_name the engine read, and a script declaring a class_name is put in the project's class list, with registered saying whether it is there. modify makes every addition or none, and answers with the line each one is on in the file written.",
17150
17315
  parameters: {
17151
17316
  projectPath: PROJECT_PATH,
17152
17317
  scriptPath: SCRIPT_PATH,
17153
- className: { type: "string", description: "create: a class_name for the script." },
17154
- extends: { type: "string", description: "create: the base class. Default Node." },
17155
- content: { type: "string", description: "create: the whole file, instead of a template." },
17318
+ className: { type: "string", description: "create: a class_name for the script. Not with content." },
17319
+ extends: { type: "string", description: "create: the base class. Default Node. Not with content." },
17320
+ content: {
17321
+ type: "string",
17322
+ description: "create: the whole file, written as given, instead of a template, className and extends."
17323
+ },
17156
17324
  template: {
17157
17325
  type: "string",
17158
17326
  enum: ["singleton", "state_machine", "component", "resource"],
@@ -17243,13 +17411,13 @@ var init_tool_definitions = __esm(() => {
17243
17411
  },
17244
17412
  {
17245
17413
  name: "editor_launch",
17246
- description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart.",
17414
+ description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again, once the old one has gone: it waits 90 seconds for that, or GDHARNESS_EDITOR_RESTART_TIMEOUT_MS, and an old editor still running after it is not replaced, since the new one would find its ports held. One opened by hand restarts itself, which only one with a window can do, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart. An open is refused when the editor it would start could never reach this server: a project other than the one this server serves, whose editor dials that project's own server; a project without the gdharness editor addon installed and enabled; and a project whose editor a restart is already bringing back.",
17247
17415
  parameters: {
17248
17416
  projectPath: { ...PROJECT_PATH, ops: ["open"] },
17249
17417
  hidden: {
17250
17418
  type: "boolean",
17251
17419
  ops: ["open", "restart"],
17252
- description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus."
17420
+ description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus. On restart only for an editor a server opened; one opened by hand comes back as it was, so hidden is refused there."
17253
17421
  }
17254
17422
  },
17255
17423
  requires: [],
@@ -17337,7 +17505,7 @@ var init_tool_definitions = __esm(() => {
17337
17505
  requires: ["projectPath"]
17338
17506
  },
17339
17507
  stop: {
17340
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled",
17508
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, and false with the number under stillGoing when what was told to end was still running ten seconds later",
17341
17509
  requires: []
17342
17510
  },
17343
17511
  check: {
@@ -17350,7 +17518,7 @@ var init_tool_definitions = __esm(() => {
17350
17518
  },
17351
17519
  {
17352
17520
  name: "editor_output",
17353
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
17521
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. For a run the editor plays the reports come from the game itself, through the runtime addon, since the editor's debugger relays what a game prints and not what it reports; a played game without the runtime addon answers here with its prints alone, with errorsUnread true and no clean, since what it raised reaches nothing here. Answers with the counts and the verdict as well as the entries. heldAt is three answers: null for a run this server knows is not held, running or over, an object for one it knows is held at a breakpoint, whether the adapter said so or the runtime did by accepting a connection and never answering, and absent with heldUnknown beside it for a server that connected to the debugger after the stop and has no runtime to ask, since the adapter reports a stop only to the sessions connected when it happened. A run that has quit still answers here, with running false and its exit code, until the next one starts; a run ended by a signal has no exit code, and exitSignal names the signal instead. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering. op: \"editor\" answers about the editor itself instead, which is a different log and the only way anything here can read what an editor printed: its console reaches no plugin, so the addon cannot scrape it, and an editor gdharness opened is asked for a log file as it starts. That covers the whole session from the editor's first line, which is where the output worth reading usually is, since the class cache, the plugins and the language server all say what they have to say before any harness has connected. The answer carries the entries in the order they were printed and, beside them under repeated, each message shape several lines share: how many, what varied in each slot of it and how many different values stood there, the first occurrence, and the ordinary lines above the group, found by walking back past the group rather than by counting lines. An editor somebody opened by hand has no console here and is refused saying so rather than answered empty, because an editor that printed nothing and an editor nobody captured are not the same thing.",
17354
17522
  parameters: {
17355
17523
  severity: {
17356
17524
  type: "string",
@@ -17398,7 +17566,7 @@ var init_tool_definitions = __esm(() => {
17398
17566
  },
17399
17567
  {
17400
17568
  name: "editor_status",
17401
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
17569
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
17402
17570
  parameters: {},
17403
17571
  requires: []
17404
17572
  },
@@ -17414,7 +17582,7 @@ var init_tool_definitions = __esm(() => {
17414
17582
  },
17415
17583
  reloadScript: {
17416
17584
  type: "string",
17417
- description: "A script to recompile in the editor after the scan, for the half a scan does not reach: a script the editor has loaded keeps the copy it built, that copy is refreshed when one of its dependencies changes rather than when it changes itself, and a scan leaves it alone. Measured here, a built copy comes back without a method added to it while script_diagnostics on the same script reads clean in the same window, so the copy the editor built and whatever the analyser resolves types against are not known to be one thing: reload for the copy, and read the diagnostics as a separate answer rather than as this one confirmed. This recompiles into the same object, so the holders that kept the stale copy alive see the new one. The methods it has afterwards come back under reloadedMethods, because a reload that compiled nothing and answered OK is the failure worth catching, and what it held before under heldBeforeReload. Its constants come back the same way, under heldConstantsBeforeReload and reloadedConstants, with each enum value as Kind.SHORT: an enum member is held as a constant and never shows among the methods. A script that does not compile is not reloaded: reloadProblem gives Godot's error, says whether the copy the editor holds survived, and names the classes the file uses, whose held copies being behind is what failed one before."
17585
+ description: "A script to recompile in the editor after the scan, for the half a scan does not reach: a script the editor has loaded keeps the copy it built, that copy is refreshed when one of its dependencies changes rather than when it changes itself, and a scan leaves it alone. Measured here, a built copy comes back without a method added to it while script_diagnostics on the same script reads clean in the same window, so the copy the editor built and whatever the analyser resolves types against are not known to be one thing: reload for the copy, and read the diagnostics as a separate answer rather than as this one confirmed. This recompiles into the same object, so the holders that kept the stale copy alive see the new one. The methods it has afterwards come back under reloadedMethods, because a reload that compiled nothing and answered OK is the failure worth catching, and what it held before under heldBeforeReload. Its constants come back the same way, under heldConstantsBeforeReload and reloadedConstants, with each enum value as Kind.SHORT: an enum member is held as a constant and never shows among the methods. A script that does not compile is not reloaded: reloadProblem gives Godot's error, says whether the copy the editor holds survived, and names the classes the file uses, whose held copies being behind is what failed one before. A @tool script is not reloaded either, since it runs inside the editor and reloading one from inside a call can take the editor down: reloadProblem says so, and editor_launch restart recompiles it. A path spelled in another case than the file is reloaded under the spelling on disk, which is the one the editor holds its copy under, and that spelling comes back under reloadedAs."
17418
17586
  }
17419
17587
  },
17420
17588
  requires: ["projectPath"]
@@ -17437,6 +17605,7 @@ var init_tool_definitions = __esm(() => {
17437
17605
  "physics2d",
17438
17606
  "physics3d",
17439
17607
  "audio",
17608
+ "visual",
17440
17609
  "animation",
17441
17610
  "ui"
17442
17611
  ],
@@ -17476,7 +17645,7 @@ var init_tool_definitions = __esm(() => {
17476
17645
  property: {
17477
17646
  type: "string",
17478
17647
  ops: ["property", "find"],
17479
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
17648
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
17480
17649
  },
17481
17650
  depth: {
17482
17651
  type: "integer",
@@ -17515,13 +17684,14 @@ var init_tool_definitions = __esm(() => {
17515
17684
  limit: {
17516
17685
  type: "integer",
17517
17686
  minimum: 1,
17687
+ maximum: 5000,
17518
17688
  ops: ["find", "text"],
17519
- description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with omitted saying how many lines that left behind. A screen whose dialog sits under a long list is a screen read with omitted greater than zero, so raise this or point root at the dialog rather than reading the answer as what is on screen."
17689
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with omitted saying how many lines that left behind. At most 5000 for either. A screen whose dialog sits under a long list is a screen read with omitted greater than zero, so raise this or point root at the dialog rather than reading the answer as what is on screen."
17520
17690
  },
17521
17691
  includeHidden: {
17522
17692
  type: "boolean",
17523
17693
  ops: ["text", "find"],
17524
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when anything above it is, and how many matches were left out comes back under hidden."
17694
+ description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when the engine would not draw it: a hidden Control, Node2D or Node3D above it hides it, a plain Node in between breaks that chain as it does for the engine, and a hidden canvas layer or window hides everything in it. How many matches were left out comes back under hidden."
17525
17695
  },
17526
17696
  metrics: {
17527
17697
  type: "array",
@@ -17560,7 +17730,7 @@ var init_tool_definitions = __esm(() => {
17560
17730
  },
17561
17731
  {
17562
17732
  name: "runtime_invoke",
17563
- description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon. The answer carries elapsed_usec, how long the call or the write took inside the game, measured around it there: the round trip to the game is a second or more, so timing from outside measures that instead. A call that takes longer than timeoutMs is not cancelled: the answer is pending: true with a requestId, the call goes on and does everything it was asked, and op result with that requestId collects its reply once it comes.",
17733
+ description: "Sets a property or calls a method on a node in the running game. Needs the game running with the runtime addon. The answer carries elapsed_usec, how long the call or the write took inside the game, measured around it there: the round trip to the game is a second or more, so timing from outside measures that instead. Errors and warnings the engine reported while a called method ran come back under errors, the first 20, with errors_not_shown counting the rest. A call that takes longer than timeoutMs is not cancelled: the answer is pending: true with a requestId, the call goes on and does everything it was asked, and op result with that requestId collects its reply once it comes.",
17564
17734
  parameters: {
17565
17735
  projectPath: RUNNING_PROJECT_PATH,
17566
17736
  pid: RUNNING_PID,
@@ -17572,12 +17742,12 @@ var init_tool_definitions = __esm(() => {
17572
17742
  property: {
17573
17743
  type: "string",
17574
17744
  ops: ["set"],
17575
- description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. The answer reads back off the same holder, and a write that leaves the property as it was, because the engine would not take it, is refused and says what the property still holds. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
17745
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. A map keyed by numbers is stepped into by the number, "_game:units:3". The answer reads back off the same holder, and a write that leaves the property as it was is refused and says what it still holds: the engine not taking it and a setter keeping the value look the same, so the refusal names neither. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
17576
17746
  },
17577
17747
  value: {
17578
17748
  ops: ["set"],
17579
17749
  blank: true,
17580
- description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
17750
+ description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. One declared as Variant or untyped takes anything, as the engine does: a value of the kind it holds is fitted to it, an int staying an int, and any other is stored as sent. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key, as is an object of another script than the one the list holds, whether or not that script has a class_name.`
17581
17751
  },
17582
17752
  method: {
17583
17753
  type: "string",
@@ -17587,7 +17757,7 @@ var init_tool_definitions = __esm(() => {
17587
17757
  args: {
17588
17758
  type: "array",
17589
17759
  ops: ["call"],
17590
- description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
17760
+ description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
17591
17761
  },
17592
17762
  timeoutMs: {
17593
17763
  type: "integer",
@@ -17620,7 +17790,7 @@ var init_tool_definitions = __esm(() => {
17620
17790
  },
17621
17791
  {
17622
17792
  name: "runtime_capture",
17623
- description: "A picture of the running game: the whole screen or one viewport, as an image. Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
17793
+ description: "A picture of the running game: the whole screen or one viewport, as an image. The whole screen includes the game's own dialogs and popups where they sit, also when a project opens them as windows of their own (subwindows not embedded). Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
17624
17794
  parameters: {
17625
17795
  projectPath: RUNNING_PROJECT_PATH,
17626
17796
  pid: RUNNING_PID,
@@ -17665,7 +17835,7 @@ var init_tool_definitions = __esm(() => {
17665
17835
  says: {
17666
17836
  type: "string",
17667
17837
  ops: ["click"],
17668
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it, is not on screen. The matches are ranked the way a person picks what to press: a button before text that cannot be pressed, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
17838
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
17669
17839
  },
17670
17840
  action: {
17671
17841
  type: "string",
@@ -17698,7 +17868,7 @@ var init_tool_definitions = __esm(() => {
17698
17868
  replace: {
17699
17869
  type: "boolean",
17700
17870
  ops: ["text"],
17701
- description: 'text: true writes over what the field already says, which is what filling one in means. Default false types at the caret, so a field reading 2.1 typed "0.3" at reads 2.10.3. The answer says what the field holds afterwards either way.'
17871
+ description: 'text: true writes over what the field already says, which is what filling one in means. Default false types at the caret, so a field reading 2.1 typed "0.3" at reads 2.10.3. The answer says what the field holds afterwards either way. A read-only field is refused.'
17702
17872
  },
17703
17873
  index: {
17704
17874
  type: "number",
@@ -17742,11 +17912,11 @@ var init_tool_definitions = __esm(() => {
17742
17912
  requires: [],
17743
17913
  operations: {
17744
17914
  click: {
17745
- summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. The control is named by nodePath, or by says with the words on it, found under nodePath when both are given, and the answer then says under found which match it was. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
17915
+ summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. The control is named by nodePath, or by says with the words on it, found under nodePath when both are given, and the answer then says under found which match it was. A control out of sight inside a ScrollContainer is scrolled to first, each container out from it a frame after the one inside, and scrolled_into_view says whether a scroll value changed. A disabled button, and a control that does not process (the game paused, say), are refused: the pointer reaches them and the press does nothing. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press: no control under the pointer stopped it, a control letting the pointer pass included, and every viewport on the way out from the room had the pointer over what shows the room",
17746
17916
  requires: []
17747
17917
  },
17748
17918
  choose: {
17749
- summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
17919
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the menu is opened, the item is looked for in it as it stands then, since a game can fill a menu as it opens, and the item takes the focus and ui_accept presses it, which is the engine's own path, needs no window, and does not depend on what ui_accept is bound to. A disabled or hidden button in front of the menu is refused, and so is a press the menu did not take, which is heard rather than assumed. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
17750
17920
  requires: ["nodePath"]
17751
17921
  },
17752
17922
  action: { summary: "press an action, or hold it", requires: ["action"] },
@@ -17755,7 +17925,7 @@ var init_tool_definitions = __esm(() => {
17755
17925
  requires: ["keycode"]
17756
17926
  },
17757
17927
  text: {
17758
- summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask",
17928
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask. The field is the focus of the dialog that has the focus when one does, which is where the keys go",
17759
17929
  requires: ["text"]
17760
17930
  },
17761
17931
  mouse_click: {
@@ -17794,7 +17964,7 @@ var init_tool_definitions = __esm(() => {
17794
17964
  },
17795
17965
  value: {
17796
17966
  ops: ["until"],
17797
- description: "until: the value to wait for, fitted to the property's type. On a property holding an object, whether declared to or untyped and holding one or null, the text null means null. One declared to hold an object refuses any value other than null, since it can never hold it."
17967
+ description: "until: the value to wait for, fitted to the property's type and compared at the precision the property keeps, so -1.1 meets a 32-bit float holding -1.10000002. A value the property would have to cut is refused: a fraction on a whole number, a number other than 0 or 1 on a bool. On a property holding an object, whether declared to or untyped and holding one or null, the text null means null. One declared to hold an object refuses any value other than null, since it can never hold it."
17798
17968
  },
17799
17969
  says: {
17800
17970
  type: "string",
@@ -17843,7 +18013,7 @@ var init_tool_definitions = __esm(() => {
17843
18013
  },
17844
18014
  {
17845
18015
  name: "debug_control",
17846
- description: "Continues or steps the debugged game through the editor's debug adapter, answering with the stack where it ended up. There is no pause and no step_out: Godot's adapter answers a pause by reporting the game stopped and leaving it running, and implements no stepOut at all, so hold the game where you want it with a breakpoint and step over or into from there.",
18016
+ description: "Continues or steps the debugged game through the editor's debug adapter. A step waits for the game to stop again and answers with the stack where it ended up; one that has not stopped the game again within ten seconds answers heldAgain false and no stack. There is no pause and no step_out: Godot's adapter answers a pause by reporting the game stopped and leaving it running, and implements no stepOut at all, so hold the game where you want it with a breakpoint and step over or into from there.",
17847
18017
  parameters: {},
17848
18018
  requires: [],
17849
18019
  operations: {
@@ -17854,12 +18024,12 @@ var init_tool_definitions = __esm(() => {
17854
18024
  },
17855
18025
  {
17856
18026
  name: "debug_state",
17857
- description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debug adapter's console output so far. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack.",
18027
+ description: "Where the debugged game is stopped: the stack trace, what is in scope at a frame with the values, or the debug adapter's console output so far, its last thousand lines, with any earlier ones counted under notShown. A stop is reported by the editor's adapter only to the sessions connected when it happened, so a server that connected afterwards, which is every server after a reconnect, is shown a thread and no frames about a game still sitting at its breakpoint. That server asks the runtime instead: a held game accepts a connection and never answers, and the refusal then says held rather than running, and that the stack cannot be read from here. debug_control continue lets it go, and the next stop is one this session is told about, with its stack.",
17858
18028
  parameters: {
17859
18029
  frameId: {
17860
18030
  type: "number",
17861
18031
  ops: ["variables"],
17862
- description: "variables: which frame, from a stack answer. Default the innermost."
18032
+ description: "variables: which frame, from a stack answer. Default the innermost. A frame the stack no longer holds is refused, and a scope whose values did not arrive in time answers variables null and is named under valuesMissing."
17863
18033
  }
17864
18034
  },
17865
18035
  requires: [],
@@ -32626,10 +32796,10 @@ var init_stdio2 = __esm(() => {
32626
32796
  });
32627
32797
 
32628
32798
  // src/runtime-client.ts
32629
- import { existsSync as existsSync11, readdirSync as readdirSync6, readFileSync as readFileSync10, statSync as statSync5, unlinkSync } from "node:fs";
32799
+ import { existsSync as existsSync12, readdirSync as readdirSync6, readFileSync as readFileSync11, statSync as statSync5, unlinkSync } from "node:fs";
32630
32800
  import { createConnection } from "node:net";
32631
32801
  import { tmpdir as tmpdir3 } from "node:os";
32632
- import { dirname as dirname7, join as join13, resolve as resolve3 } from "node:path";
32802
+ import { dirname as dirname8, join as join13, resolve as resolve3 } from "node:path";
32633
32803
  import { setTimeout as delay } from "node:timers/promises";
32634
32804
  function runtimeDirectory(variables = process.env) {
32635
32805
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -32650,7 +32820,7 @@ function runtimeDirectories(variables = process.env) {
32650
32820
  function errorReportOf(pid, directories = runtimeDirectories()) {
32651
32821
  for (const directory of directories) {
32652
32822
  const path = join13(directory, `runtime-${pid}.log`);
32653
- if (existsSync11(path)) {
32823
+ if (existsSync12(path)) {
32654
32824
  return path;
32655
32825
  }
32656
32826
  }
@@ -32681,7 +32851,7 @@ function reportAlreadyInTranscript(transcript, report) {
32681
32851
  function parseAnnouncement(file, pid) {
32682
32852
  let fields;
32683
32853
  try {
32684
- fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
32854
+ fields = asParams(JSON.parse(readFileSync11(file, "utf8")));
32685
32855
  } catch {
32686
32856
  return { kind: "rubbish" };
32687
32857
  }
@@ -32718,6 +32888,12 @@ function parseAnnouncement(file, pid) {
32718
32888
  }
32719
32889
  };
32720
32890
  }
32891
+ function dialAddress(address) {
32892
+ if (address === "*" || address === "0.0.0.0") {
32893
+ return "127.0.0.1";
32894
+ }
32895
+ return address === "::" ? "::1" : address;
32896
+ }
32721
32897
  function runtimesAnnounced(directories = runtimeDirectories()) {
32722
32898
  const running = new Map;
32723
32899
  const unspoken = new Map;
@@ -32739,7 +32915,7 @@ function discoverRuntimes(directories = runtimeDirectories()) {
32739
32915
  function wentAway(file, pid) {
32740
32916
  let project = "";
32741
32917
  try {
32742
- const fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
32918
+ const fields = asParams(JSON.parse(readFileSync11(file, "utf8")));
32743
32919
  project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
32744
32920
  } catch {
32745
32921
  return;
@@ -32782,7 +32958,7 @@ function strangersAmong(candidates, now, startTimes = startTimesOf) {
32782
32958
  }).map((one) => one.file));
32783
32959
  }
32784
32960
  function announcedIn(directory) {
32785
- if (!existsSync11(directory)) {
32961
+ if (!existsSync12(directory)) {
32786
32962
  return [];
32787
32963
  }
32788
32964
  const found = [];
@@ -32828,14 +33004,14 @@ function announcedIn(directory) {
32828
33004
  const held = new Set(entries.map((one) => one.file));
32829
33005
  const here = join13(directory, ".");
32830
33006
  for (const file of verdicts.keys()) {
32831
- if (dirname7(file) === here && !held.has(file)) {
33007
+ if (dirname8(file) === here && !held.has(file)) {
32832
33008
  verdicts.delete(file);
32833
33009
  }
32834
33010
  }
32835
33011
  return found;
32836
33012
  }
32837
33013
  async function announcementEnded(pid, directories = runtimeDirectories()) {
32838
- const files = directories.map((directory) => join13(directory, `runtime-${pid}.json`)).filter((file) => existsSync11(file));
33014
+ const files = directories.map((directory) => join13(directory, `runtime-${pid}.json`)).filter((file) => existsSync12(file));
32839
33015
  const first = files[0];
32840
33016
  if (first === undefined) {
32841
33017
  return;
@@ -32870,13 +33046,13 @@ async function announcedSince(projectPath, before, waiting = {}) {
32870
33046
  const directories = waiting.directories ?? runtimeDirectories();
32871
33047
  const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
32872
33048
  for (;; ) {
32873
- const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
32874
- if (fresh !== undefined) {
33049
+ const fresh = discoverRuntimes(directories).filter((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
33050
+ if (fresh.length > 0) {
32875
33051
  return fresh;
32876
33052
  }
32877
33053
  const left = until - Date.now();
32878
33054
  if (left <= 0 || waiting.giveUp?.() === true) {
32879
- return null;
33055
+ return [];
32880
33056
  }
32881
33057
  await delay(Math.min(LOOK_EVERY_MS, left));
32882
33058
  }
@@ -33007,7 +33183,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = fa
33007
33183
  let late = false;
33008
33184
  let buffered = "";
33009
33185
  let welcomed = false;
33010
- const socket = createConnection({ port: endpoint.port, host: endpoint.address });
33186
+ const socket = createConnection({ port: endpoint.port, host: dialAddress(endpoint.address) });
33011
33187
  let lateTimer = null;
33012
33188
  const finish = (reply) => {
33013
33189
  if (late) {
@@ -33135,14 +33311,14 @@ var init_runtime_client = __esm(() => {
33135
33311
  });
33136
33312
 
33137
33313
  // src/boot-note.ts
33138
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
33139
- import { dirname as dirname8, join as join14 } from "node:path";
33314
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "node:fs";
33315
+ import { dirname as dirname9, join as join14 } from "node:path";
33140
33316
  function bootNotePath(projectPath) {
33141
33317
  return join14(projectPath, ".godot", "gdharness-boot.json");
33142
33318
  }
33143
33319
  function readBootNote(projectPath) {
33144
33320
  try {
33145
- const noted = readNumber(asParams(JSON.parse(readFileSync11(bootNotePath(projectPath), "utf8"))), "announcedAfterMs");
33321
+ const noted = readNumber(asParams(JSON.parse(readFileSync12(bootNotePath(projectPath), "utf8"))), "announcedAfterMs");
33146
33322
  return noted !== undefined && Number.isFinite(noted) && noted > 0 ? noted : null;
33147
33323
  } catch {
33148
33324
  return null;
@@ -33151,7 +33327,7 @@ function readBootNote(projectPath) {
33151
33327
  function writeBootNote(projectPath, announcedAfterMs) {
33152
33328
  try {
33153
33329
  const path = bootNotePath(projectPath);
33154
- mkdirSync6(dirname8(path), { recursive: true });
33330
+ mkdirSync6(dirname9(path), { recursive: true });
33155
33331
  writeFileSync8(path, JSON.stringify({ announcedAfterMs: Math.round(announcedAfterMs), at: new Date().toISOString() }, null, 2), "utf8");
33156
33332
  } catch {}
33157
33333
  }
@@ -33170,8 +33346,8 @@ var init_boot_note = __esm(() => {
33170
33346
  });
33171
33347
 
33172
33348
  // src/breakpoint-note.ts
33173
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
33174
- import { dirname as dirname9, isAbsolute as isAbsolute2, join as join15, relative as relative2 } from "node:path";
33349
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
33350
+ import { dirname as dirname10, isAbsolute as isAbsolute2, join as join15, relative as relative2 } from "node:path";
33175
33351
  function breakpointNotePath(projectPath) {
33176
33352
  return join15(projectPath, ".godot", "gdharness-breakpoints.json");
33177
33353
  }
@@ -33190,7 +33366,7 @@ function writeBreakpointNote(projectPath, held) {
33190
33366
  lines: [...one.lines]
33191
33367
  }))
33192
33368
  };
33193
- mkdirSync7(dirname9(path), { recursive: true });
33369
+ mkdirSync7(dirname10(path), { recursive: true });
33194
33370
  writeFileSync9(path, `${JSON.stringify(note, null, 2)}
33195
33371
  `, "utf8");
33196
33372
  } catch (error) {
@@ -33199,7 +33375,7 @@ function writeBreakpointNote(projectPath, held) {
33199
33375
  }
33200
33376
  function readBreakpointNote(projectPath) {
33201
33377
  try {
33202
- const parsed = JSON.parse(readFileSync12(breakpointNotePath(projectPath), "utf8"));
33378
+ const parsed = JSON.parse(readFileSync13(breakpointNotePath(projectPath), "utf8"));
33203
33379
  if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.breakpoints)) {
33204
33380
  return [];
33205
33381
  }
@@ -33232,14 +33408,14 @@ var init_breakpoint_note = __esm(() => {
33232
33408
  });
33233
33409
 
33234
33410
  // src/class-note.ts
33235
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync13, writeFileSync as writeFileSync10 } from "node:fs";
33236
- import { dirname as dirname10, join as join16 } from "node:path";
33411
+ import { mkdirSync as mkdirSync8, readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "node:fs";
33412
+ import { dirname as dirname11, join as join16 } from "node:path";
33237
33413
  function classNotePath(projectPath) {
33238
33414
  return join16(projectPath, ".godot", "gdharness-classes.json");
33239
33415
  }
33240
33416
  function readClassNote(projectPath) {
33241
33417
  try {
33242
- const fields = asParams(JSON.parse(readFileSync13(classNotePath(projectPath), "utf8")));
33418
+ const fields = asParams(JSON.parse(readFileSync14(classNotePath(projectPath), "utf8")));
33243
33419
  const rebuiltWith = readArray(fields, "rebuiltWith");
33244
33420
  if (rebuiltWith === undefined || !rebuiltWith.every((name) => typeof name === "string")) {
33245
33421
  return null;
@@ -33256,7 +33432,7 @@ function readClassNote(projectPath) {
33256
33432
  function writeClassNote(projectPath, note) {
33257
33433
  try {
33258
33434
  const path = classNotePath(projectPath);
33259
- mkdirSync8(dirname10(path), { recursive: true });
33435
+ mkdirSync8(dirname11(path), { recursive: true });
33260
33436
  writeFileSync10(path, `${JSON.stringify(note, null, 2)}
33261
33437
  `, "utf8");
33262
33438
  } catch {}
@@ -33264,16 +33440,16 @@ function writeClassNote(projectPath, note) {
33264
33440
  var init_class_note = () => {};
33265
33441
 
33266
33442
  // src/config-pin.ts
33267
- import { existsSync as existsSync12, readFileSync as readFileSync14 } from "node:fs";
33443
+ import { existsSync as existsSync13, readFileSync as readFileSync15 } from "node:fs";
33268
33444
  import { join as join17 } from "node:path";
33269
33445
  function versionsNamedInConfig(projectPath) {
33270
33446
  const config = join17(projectPath, ".mcp.json");
33271
- if (!existsSync12(config)) {
33447
+ if (!existsSync13(config)) {
33272
33448
  return [];
33273
33449
  }
33274
33450
  let text;
33275
33451
  try {
33276
- text = readFileSync14(config, "utf8");
33452
+ text = readFileSync15(config, "utf8");
33277
33453
  JSON.parse(text);
33278
33454
  } catch {
33279
33455
  return [];
@@ -33422,6 +33598,9 @@ var init_ports = __esm(() => {
33422
33598
  // src/dap_client.ts
33423
33599
  import { createConnection as createConnection2 } from "node:net";
33424
33600
  import { setTimeout as delay2 } from "node:timers/promises";
33601
+ function fileKey(path) {
33602
+ return process.platform === "win32" ? path.replaceAll("\\", "/").toLowerCase() : path;
33603
+ }
33425
33604
  function said(body, field) {
33426
33605
  const value = body?.[field];
33427
33606
  return typeof value === "string" ? value : "";
@@ -33438,17 +33617,25 @@ class GodotDAPClient {
33438
33617
  outputBuffer = [];
33439
33618
  onOutput = null;
33440
33619
  maxOutputLines = 1000;
33620
+ outputDropped = 0;
33621
+ outputTaken = 0;
33441
33622
  initialized = false;
33442
33623
  attached = false;
33443
33624
  lastThreadId = 1;
33444
33625
  halt = null;
33445
33626
  holdKnown = false;
33627
+ stops = 0;
33446
33628
  breakpoints = new Map;
33629
+ spelled = new Map;
33447
33630
  inEditor = new Map;
33448
33631
  onBreakpointsChanged = null;
33449
- constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
33632
+ scopesPatienceMs;
33633
+ stepPatienceMs;
33634
+ constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1", patience = {}) {
33450
33635
  this.port = port;
33451
33636
  this.host = host;
33637
+ this.scopesPatienceMs = patience.scopesMs ?? SCOPES_TIMEOUT_MS;
33638
+ this.stepPatienceMs = patience.stepMs ?? STEP_LANDS_WITHIN_MS;
33452
33639
  this.pendingRequests = new Map;
33453
33640
  }
33454
33641
  async connect() {
@@ -33609,6 +33796,7 @@ class GodotDAPClient {
33609
33796
  }
33610
33797
  }
33611
33798
  if (this.outputBuffer.length > this.maxOutputLines) {
33799
+ this.outputDropped += this.outputBuffer.length - this.maxOutputLines;
33612
33800
  this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - this.maxOutputLines);
33613
33801
  }
33614
33802
  return;
@@ -33625,6 +33813,7 @@ class GodotDAPClient {
33625
33813
  text: said(body, "text")
33626
33814
  };
33627
33815
  this.holdKnown = true;
33816
+ this.stops += 1;
33628
33817
  return;
33629
33818
  }
33630
33819
  if (eventName === "continued") {
@@ -33641,20 +33830,24 @@ class GodotDAPClient {
33641
33830
  if (typeof path !== "string" || typeof line !== "number") {
33642
33831
  return;
33643
33832
  }
33833
+ const key = fileKey(path);
33834
+ if (!this.spelled.has(key)) {
33835
+ this.spelled.set(key, path);
33836
+ }
33644
33837
  if (said(body, "reason") === "removed") {
33645
- this.inEditor.get(path)?.delete(line);
33646
- const held = this.breakpoints.get(path);
33838
+ this.inEditor.get(key)?.delete(line);
33839
+ const held = this.breakpoints.get(key);
33647
33840
  if (held?.delete(line) === true) {
33648
33841
  if (held.size === 0) {
33649
- this.breakpoints.delete(path);
33842
+ this.breakpoints.delete(key);
33650
33843
  }
33651
33844
  this.onBreakpointsChanged?.(this.breakpointsHeld());
33652
33845
  }
33653
33846
  return;
33654
33847
  }
33655
- const known = this.inEditor.get(path) ?? new Set;
33848
+ const known = this.inEditor.get(key) ?? new Set;
33656
33849
  known.add(line);
33657
- this.inEditor.set(path, known);
33850
+ this.inEditor.set(key, known);
33658
33851
  return;
33659
33852
  }
33660
33853
  if (eventName === "terminated" || eventName === "exited") {
@@ -33737,24 +33930,44 @@ class GodotDAPClient {
33737
33930
  getOutput(clear = false) {
33738
33931
  const lines = [...this.outputBuffer];
33739
33932
  if (clear) {
33933
+ this.outputTaken += lines.length;
33740
33934
  this.outputBuffer = [];
33741
33935
  }
33742
33936
  return lines;
33743
33937
  }
33938
+ outputSoFar() {
33939
+ const output = this.getOutput(false);
33940
+ const notShown = this.outputDropped + this.outputTaken;
33941
+ if (notShown === 0) {
33942
+ return { lines: output.length, output };
33943
+ }
33944
+ const where = [
33945
+ this.outputDropped > 0 ? `${this.outputDropped} fell out of this buffer, which keeps the last ${this.maxOutputLines}` : "",
33946
+ this.outputTaken > 0 ? `${this.outputTaken} were moved into the run's log by an earlier read` : ""
33947
+ ].filter((part) => part !== "");
33948
+ return {
33949
+ lines: output.length,
33950
+ output,
33951
+ notShown,
33952
+ note: `${notShown} earlier ${notShown === 1 ? "line is" : "lines are"} not in this answer: ${where.join(", and ")}. editor_output reads what the server kept of the run's console.`
33953
+ };
33954
+ }
33744
33955
  async setBreakpoint(filePath, line) {
33745
- const wanted = new Set(this.breakpoints.get(filePath) ?? []);
33956
+ const wanted = new Set(this.breakpoints.get(fileKey(filePath)) ?? []);
33746
33957
  wanted.add(line);
33747
33958
  return await this.sendBreakpoints(filePath, wanted);
33748
33959
  }
33749
33960
  async removeBreakpoint(filePath, line) {
33750
- const wanted = new Set(this.breakpoints.get(filePath) ?? []);
33961
+ const wanted = new Set(this.breakpoints.get(fileKey(filePath)) ?? []);
33751
33962
  wanted.delete(line);
33752
33963
  return await this.sendBreakpoints(filePath, wanted, line);
33753
33964
  }
33754
33965
  async sendBreakpoints(filePath, mine, clearing) {
33755
33966
  await this.ensureConnected();
33756
33967
  await this.initialize();
33757
- const lines = new Set([...mine, ...this.inEditor.get(filePath) ?? []]);
33968
+ const key = fileKey(filePath);
33969
+ this.spelled.set(key, filePath);
33970
+ const lines = new Set([...mine, ...this.inEditor.get(key) ?? []]);
33758
33971
  if (clearing !== undefined) {
33759
33972
  lines.delete(clearing);
33760
33973
  }
@@ -33764,14 +33977,14 @@ class GodotDAPClient {
33764
33977
  breakpoints: sorted.map((breakpointLine) => ({ line: breakpointLine }))
33765
33978
  });
33766
33979
  if (mine.size === 0) {
33767
- this.breakpoints.delete(filePath);
33980
+ this.breakpoints.delete(key);
33768
33981
  } else {
33769
- this.breakpoints.set(filePath, new Set(mine));
33982
+ this.breakpoints.set(key, new Set(mine));
33770
33983
  }
33771
33984
  if (lines.size === 0) {
33772
- this.inEditor.delete(filePath);
33985
+ this.inEditor.delete(key);
33773
33986
  } else {
33774
- this.inEditor.set(filePath, lines);
33987
+ this.inEditor.set(key, lines);
33775
33988
  }
33776
33989
  return answer;
33777
33990
  }
@@ -33780,11 +33993,11 @@ class GodotDAPClient {
33780
33993
  }
33781
33994
  breakpointsInEditor() {
33782
33995
  const theirs = [];
33783
- for (const [scriptPath, lines] of this.inEditor) {
33784
- const mine = this.breakpoints.get(scriptPath);
33996
+ for (const [key, lines] of this.inEditor) {
33997
+ const mine = this.breakpoints.get(key);
33785
33998
  const rest = Array.from(lines).filter((line) => !mine?.has(line)).sort((a, b) => a - b);
33786
33999
  if (rest.length > 0) {
33787
- theirs.push({ scriptPath, lines: rest });
34000
+ theirs.push({ scriptPath: this.spelled.get(key) ?? key, lines: rest });
33788
34001
  }
33789
34002
  }
33790
34003
  return theirs;
@@ -33792,12 +34005,13 @@ class GodotDAPClient {
33792
34005
  async reapplyBreakpoints() {
33793
34006
  const applied = [];
33794
34007
  const refused = [];
33795
- for (const [filePath, lines] of Array.from(this.breakpoints)) {
34008
+ for (const [key, lines] of Array.from(this.breakpoints)) {
34009
+ const filePath = this.spelled.get(key) ?? key;
33796
34010
  try {
33797
34011
  await this.sendBreakpoints(filePath, lines);
33798
34012
  applied.push({ scriptPath: filePath, lines: Array.from(lines).sort((a, b) => a - b) });
33799
34013
  } catch (error) {
33800
- this.breakpoints.delete(filePath);
34014
+ this.breakpoints.delete(key);
33801
34015
  refused.push({
33802
34016
  scriptPath: filePath,
33803
34017
  reason: error instanceof Error ? error.message : String(error)
@@ -33810,19 +34024,21 @@ class GodotDAPClient {
33810
34024
  return { applied, refused };
33811
34025
  }
33812
34026
  breakpointsHeld() {
33813
- return Array.from(this.breakpoints, ([scriptPath, lines]) => ({
33814
- scriptPath,
34027
+ return Array.from(this.breakpoints, ([key, lines]) => ({
34028
+ scriptPath: this.spelled.get(key) ?? key,
33815
34029
  lines: Array.from(lines).sort((a, b) => a - b)
33816
34030
  }));
33817
34031
  }
33818
34032
  holdBreakpoints(held) {
33819
34033
  for (const { scriptPath, lines } of held) {
33820
- const fileBreakpoints = this.breakpoints.get(scriptPath) ?? new Set;
34034
+ const key = fileKey(scriptPath);
34035
+ const fileBreakpoints = this.breakpoints.get(key) ?? new Set;
33821
34036
  for (const line of lines) {
33822
34037
  fileBreakpoints.add(line);
33823
34038
  }
33824
34039
  if (fileBreakpoints.size > 0) {
33825
- this.breakpoints.set(scriptPath, fileBreakpoints);
34040
+ this.breakpoints.set(key, fileBreakpoints);
34041
+ this.spelled.set(key, scriptPath);
33826
34042
  }
33827
34043
  }
33828
34044
  }
@@ -33833,15 +34049,16 @@ class GodotDAPClient {
33833
34049
  this.halt = null;
33834
34050
  this.holdKnown = true;
33835
34051
  }
33836
- async stepOver(threadId) {
33837
- await this.attach();
33838
- const resolvedThreadId = await this.resolveThreadId(threadId);
33839
- await this.sendRequest("next", { threadId: resolvedThreadId });
33840
- }
33841
- async stepInto(threadId) {
34052
+ async step(command) {
33842
34053
  await this.attach();
33843
- const resolvedThreadId = await this.resolveThreadId(threadId);
33844
- await this.sendRequest("stepIn", { threadId: resolvedThreadId });
34054
+ const resolvedThreadId = await this.resolveThreadId();
34055
+ const before = this.stops;
34056
+ await this.sendRequest(command, { threadId: resolvedThreadId });
34057
+ const deadline = Date.now() + this.stepPatienceMs;
34058
+ while (this.stops === before && this.connected && Date.now() < deadline) {
34059
+ await delay2(50);
34060
+ }
34061
+ return this.stops !== before;
33845
34062
  }
33846
34063
  async getStackTrace(threadId) {
33847
34064
  await this.attach();
@@ -33867,16 +34084,16 @@ class GodotDAPClient {
33867
34084
  } catch {}
33868
34085
  await delay2(200);
33869
34086
  }
33870
- return [];
34087
+ return null;
33871
34088
  }
33872
34089
  async getScopes(frameId) {
33873
34090
  await this.attach();
33874
34091
  const frames = await this.getStackTrace();
33875
34092
  const frame = frameId === undefined ? frames[0] : frames.find((each) => each["id"] === frameId);
33876
34093
  if (frame === undefined) {
33877
- return [];
34094
+ throw new ReadRefusal(frames.length === 0 ? "The adapter gave no stack, so there is no frame to read variables from" : `The stack the adapter gives now has no frame ${String(frameId)}; its frames are ${frames.map((each) => String(each["id"])).join(", ")}. Read the stack again for the frames the game is at now`);
33878
34095
  }
33879
- const deadline = Date.now() + SCOPES_TIMEOUT_MS;
34096
+ const deadline = Date.now() + this.scopesPatienceMs;
33880
34097
  let scopes = [];
33881
34098
  while (scopes.length === 0 && Date.now() < deadline) {
33882
34099
  try {
@@ -33888,6 +34105,9 @@ class GodotDAPClient {
33888
34105
  } catch {}
33889
34106
  await delay2(200);
33890
34107
  }
34108
+ if (scopes.length === 0) {
34109
+ throw new ReadRefusal(`The scopes of frame ${String(frame["id"])} did not arrive from the game within ${this.scopesPatienceMs / 1000}s. They come from the game through the editor, so a busy machine can take longer: ask again`);
34110
+ }
33891
34111
  const named = [];
33892
34112
  for (const scope of scopes) {
33893
34113
  const reference = scope["variablesReference"];
@@ -33951,9 +34171,8 @@ async function handleDAPTool(client, toolName, args) {
33951
34171
  try {
33952
34172
  switch (toolName) {
33953
34173
  case "dap_get_output": {
33954
- const output = client.getOutput(false);
33955
34174
  return {
33956
- content: [{ type: "text", text: JSON.stringify({ lines: output.length, output }, null, 2) }]
34175
+ content: [{ type: "text", text: JSON.stringify(client.outputSoFar(), null, 2) }]
33957
34176
  };
33958
34177
  }
33959
34178
  case "dap_set_breakpoint": {
@@ -33980,19 +34199,13 @@ async function handleDAPTool(client, toolName, args) {
33980
34199
  }
33981
34200
  case "dap_step_over":
33982
34201
  case "dap_step_into": {
33983
- if (toolName === "dap_step_into") {
33984
- await client.stepInto();
33985
- } else {
33986
- await client.stepOver();
33987
- }
33988
- return {
33989
- content: [
33990
- {
33991
- type: "text",
33992
- text: JSON.stringify({ stepped: true, stack: await client.getStackTrace() }, null, 2)
33993
- }
33994
- ]
34202
+ const heldAgain = await client.step(toolName === "dap_step_into" ? "stepIn" : "next");
34203
+ const answer = heldAgain ? { stepped: true, heldAgain, stack: await client.getStackTrace() } : {
34204
+ stepped: true,
34205
+ heldAgain,
34206
+ note: `The game had not stopped again ${client.stepPatienceMs / 1000}s after the step: the step is still running, it went on without stopping anywhere, or the game was not held when it was asked. debug_state stack answers once it stops.`
33995
34207
  };
34208
+ return { content: [{ type: "text", text: JSON.stringify(answer, null, 2) }] };
33996
34209
  }
33997
34210
  case "dap_get_stack_trace": {
33998
34211
  const stack = await client.getStackTrace();
@@ -34003,8 +34216,14 @@ async function handleDAPTool(client, toolName, args) {
34003
34216
  case "dap_get_variables": {
34004
34217
  const frameId = typeof safeArgs.frameId === "number" ? safeArgs.frameId : undefined;
34005
34218
  const scopes = await client.getScopes(frameId);
34219
+ const missing = scopes.filter((scope) => scope.variables === null).map((scope) => scope.name);
34220
+ const answer = missing.length === 0 ? { scopes } : {
34221
+ scopes,
34222
+ valuesMissing: missing,
34223
+ note: `The values of ${missing.join(", ")} did not arrive from the game within ${client.scopesPatienceMs / 1000}s, so they are unknown rather than empty. Ask again.`
34224
+ };
34006
34225
  return {
34007
- content: [{ type: "text", text: JSON.stringify({ scopes }, null, 2) }]
34226
+ content: [{ type: "text", text: JSON.stringify(answer, null, 2) }]
34008
34227
  };
34009
34228
  }
34010
34229
  default:
@@ -34012,18 +34231,22 @@ async function handleDAPTool(client, toolName, args) {
34012
34231
  }
34013
34232
  } catch (error) {
34014
34233
  const message = error instanceof Error ? error.message : String(error);
34015
- const cure = toolName === "dap_set_breakpoint" || toolName === "dap_remove_breakpoint" ? "Breakpoints need the editor open, not a running game: editor_status says whether one is connected, and the path is the script as the project spells it." : "The debug tools answer for a game the editor is playing: start one with editor_run.";
34234
+ const cure = error instanceof ReadRefusal ? "" : toolName === "dap_set_breakpoint" || toolName === "dap_remove_breakpoint" ? "Breakpoints need the editor open, not a running game: editor_status says whether one is connected, and the path is the script as the project spells it." : "The debug tools answer for a game the editor is playing: start one with editor_run.";
34016
34235
  return {
34017
34236
  isError: true,
34018
- content: [{ type: "text", text: `DAP tool '${toolName}' failed: ${message}. ${cure}` }]
34237
+ content: [
34238
+ { type: "text", text: `DAP tool '${toolName}' failed: ${message}.${cure === "" ? "" : ` ${cure}`}` }
34239
+ ]
34019
34240
  };
34020
34241
  }
34021
34242
  }
34022
- var DEFAULT_DAP_PORT = 6006, DAP_REQUEST_TIMEOUT_MS = 1e4, SCOPES_TIMEOUT_MS = 20000, VARIABLES_POLL_TIMEOUT_MS = 2000;
34243
+ var DEFAULT_DAP_PORT = 6006, DAP_REQUEST_TIMEOUT_MS = 1e4, SCOPES_TIMEOUT_MS = 20000, STEP_LANDS_WITHIN_MS = 1e4, VARIABLES_POLL_TIMEOUT_MS = 2000, ReadRefusal;
34023
34244
  var init_dap_client = __esm(() => {
34024
34245
  init_errors();
34025
34246
  init_framing();
34026
34247
  init_ports();
34248
+ ReadRefusal = class ReadRefusal extends Refusal {
34249
+ };
34027
34250
  });
34028
34251
 
34029
34252
  // src/desktop.ts
@@ -34033,16 +34256,16 @@ var init_desktop = () => {};
34033
34256
  // src/editor-log.ts
34034
34257
  import {
34035
34258
  closeSync,
34036
- existsSync as existsSync13,
34259
+ existsSync as existsSync14,
34037
34260
  mkdirSync as mkdirSync9,
34038
34261
  openSync,
34039
- readFileSync as readFileSync15,
34262
+ readFileSync as readFileSync16,
34040
34263
  readSync,
34041
34264
  rmSync as rmSync8,
34042
34265
  statSync as statSync6,
34043
34266
  writeFileSync as writeFileSync11
34044
34267
  } from "node:fs";
34045
- import { dirname as dirname11, join as join18 } from "node:path";
34268
+ import { dirname as dirname12, join as join18 } from "node:path";
34046
34269
  function editorLogPath(projectPath) {
34047
34270
  return join18(projectPath, ".godot", "gdharness-editor.log");
34048
34271
  }
@@ -34051,7 +34274,7 @@ function editorLogNotePath(projectPath) {
34051
34274
  }
34052
34275
  function readEditorLogNote(projectPath) {
34053
34276
  try {
34054
- const fields = asParams(JSON.parse(readFileSync15(editorLogNotePath(projectPath), "utf8")));
34277
+ const fields = asParams(JSON.parse(readFileSync16(editorLogNotePath(projectPath), "utf8")));
34055
34278
  const pid = readNumber(fields, "pid");
34056
34279
  const startedAt = readString(fields, "startedAt");
34057
34280
  return pid !== undefined && Number.isInteger(pid) && pid > 0 && startedAt !== undefined ? { pid, startedAt } : null;
@@ -34062,7 +34285,7 @@ function readEditorLogNote(projectPath) {
34062
34285
  function clearEditorLog(projectPath) {
34063
34286
  try {
34064
34287
  const path = editorLogPath(projectPath);
34065
- mkdirSync9(dirname11(path), { recursive: true });
34288
+ mkdirSync9(dirname12(path), { recursive: true });
34066
34289
  rmSync8(path, { force: true });
34067
34290
  rmSync8(editorLogNotePath(projectPath), { force: true });
34068
34291
  } catch {}
@@ -34070,7 +34293,7 @@ function clearEditorLog(projectPath) {
34070
34293
  function writeEditorLogNote(projectPath, pid) {
34071
34294
  try {
34072
34295
  const path = editorLogNotePath(projectPath);
34073
- mkdirSync9(dirname11(path), { recursive: true });
34296
+ mkdirSync9(dirname12(path), { recursive: true });
34074
34297
  writeFileSync11(path, JSON.stringify({ pid, startedAt: new Date().toISOString() }, null, 2), "utf8");
34075
34298
  } catch {}
34076
34299
  }
@@ -34092,7 +34315,7 @@ function editorConsole(projectPath, editorPid, openedByAServer) {
34092
34315
  return { kind: "older server", notedPid: noted?.pid ?? null };
34093
34316
  }
34094
34317
  const path = editorLogPath(projectPath);
34095
- if (!existsSync13(path)) {
34318
+ if (!existsSync14(path)) {
34096
34319
  return { kind: "not written" };
34097
34320
  }
34098
34321
  const log = new GameLog;
@@ -34104,7 +34327,7 @@ function editorConsole(projectPath, editorPid, openedByAServer) {
34104
34327
  function theFirstOf(path) {
34105
34328
  const whole = statSync6(path).size;
34106
34329
  if (whole <= MOST_CONSOLE_BYTES) {
34107
- return { text: readFileSync15(path, "utf8") };
34330
+ return { text: readFileSync16(path, "utf8") };
34108
34331
  }
34109
34332
  const handle = openSync(path, "r");
34110
34333
  try {
@@ -37173,7 +37396,10 @@ function mayYetConnect(listeningSince, now = Date.now()) {
37173
37396
  return listeningSince === undefined || now - listeningSince.getTime() < CONNECT_WINDOW_MS;
37174
37397
  }
37175
37398
  function theEditorHasComeBack(status, startedAt) {
37176
- return status.connected && (status.connectedAt?.getTime() ?? 0) > startedAt && status.addonVersion !== undefined;
37399
+ return hasSaidWhoItIs(status) && (status.connectedAt?.getTime() ?? 0) > startedAt;
37400
+ }
37401
+ function hasSaidWhoItIs(status) {
37402
+ return status.connected && status.addonVersion !== undefined;
37177
37403
  }
37178
37404
  function anEditorIsStillComing(listeningSince, launchedEditorIsAlive, now = Date.now()) {
37179
37405
  return mayYetConnect(listeningSince, now) || launchedEditorIsAlive;
@@ -37332,6 +37558,7 @@ var init_godot_bridge = __esm(() => {
37332
37558
  dapPort: this.connectionInfo?.dapPort,
37333
37559
  debugPort: this.connectionInfo?.debugPort,
37334
37560
  openedByAServer: this.connectionInfo?.openedByAServer,
37561
+ startedByAnEditor: this.connectionInfo?.startedByAnEditor,
37335
37562
  syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
37336
37563
  pendingRequests: this.pendingRequests.size,
37337
37564
  queuedResources: this.resourceQueues.size,
@@ -37477,6 +37704,7 @@ var init_godot_bridge = __esm(() => {
37477
37704
  this.connectionInfo.dapPort = servedPort(message.dap_port);
37478
37705
  this.connectionInfo.debugPort = servedPort(message.debug_port);
37479
37706
  this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
37707
+ this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
37480
37708
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
37481
37709
  this.log("info", `Godot ready: ${message.project_path}`);
37482
37710
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
@@ -37798,13 +38026,18 @@ function parseJUnit(xml) {
37798
38026
  const suiteElements = top.name === "testsuite" ? [top] : top.children.filter((child) => child.name === "testsuite");
37799
38027
  const suites = suiteElements.map((suite) => {
37800
38028
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
38029
+ const failures = cases.filter((entry) => entry.status === "failed").length;
38030
+ const errors = cases.filter((entry) => entry.status === "error").length;
38031
+ const written = (kind) => suite.children.filter((child) => child.name === "testcase").reduce((sum, element) => sum + element.children.filter((child) => child.name === kind).length, 0);
37801
38032
  return {
37802
38033
  name: suite.attributes["name"] ?? "",
37803
38034
  path: pathOf(suite),
37804
38035
  tests: cases.length,
37805
38036
  discovered: count(suite.attributes, "tests"),
37806
- failures: cases.filter((entry) => entry.status === "failed").length,
37807
- errors: cases.filter((entry) => entry.status === "error").length,
38037
+ failures,
38038
+ errors,
38039
+ hookFailures: Math.max(0, count(suite.attributes, "failures") - written("failure")),
38040
+ hookErrors: Math.max(0, count(suite.attributes, "errors") - written("error")),
37808
38041
  skipped: cases.filter((entry) => entry.status === "skipped").length,
37809
38042
  time: count(suite.attributes, "time"),
37810
38043
  cases
@@ -37815,11 +38048,19 @@ function parseJUnit(xml) {
37815
38048
  tests: all.length,
37816
38049
  failures: all.filter((entry) => entry.status === "failed").length,
37817
38050
  errors: all.filter((entry) => entry.status === "error").length,
38051
+ hookFailures: suites.reduce((sum, suite) => sum + suite.hookFailures, 0),
38052
+ hookErrors: suites.reduce((sum, suite) => sum + suite.hookErrors, 0),
37818
38053
  skipped: all.filter((entry) => entry.status === "skipped").length,
37819
38054
  time: suites.reduce((sum, suite) => sum + suite.time, 0),
37820
38055
  suites
37821
38056
  };
37822
38057
  }
38058
+ function hookFailuresPrinted(printed) {
38059
+ return printed.flatMap((line) => {
38060
+ const found = HOOK_FAILURE.exec(line);
38061
+ return found === null ? [] : [{ hook: found[2] ?? "", path: found[3] ?? "", line: Number(found[4]), message: found[1] ?? "" }];
38062
+ });
38063
+ }
37823
38064
  function whyNoReport(printed, asked) {
37824
38065
  const missing = printed.map((line) => NO_SUCH_PATH.exec(line)).find((found) => found !== null);
37825
38066
  if (missing !== undefined) {
@@ -37884,13 +38125,17 @@ function withActualsPrinted(failed, printed) {
37884
38125
  at: match[5] ?? "",
37885
38126
  ...readDiff(match[1] ?? "")
37886
38127
  }));
38128
+ const used = new Set;
37887
38129
  return failed.map((entry) => {
37888
38130
  if (entry.detail === null) {
37889
38131
  return entry;
37890
38132
  }
37891
38133
  let detail = entry.detail;
37892
38134
  let recovered = false;
37893
- for (const diff of diffs) {
38135
+ for (const [index, diff] of diffs.entries()) {
38136
+ if (used.has(index)) {
38137
+ continue;
38138
+ }
37894
38139
  const wrong = ` but was
37895
38140
  '${diff.merged.replaceAll("\r", "")}'${diff.suffix}
37896
38141
  at ${diff.at}`;
@@ -37899,6 +38144,7 @@ function withActualsPrinted(failed, printed) {
37899
38144
  '${diff.actual}'${diff.suffix}
37900
38145
  at ${diff.at}`);
37901
38146
  recovered = true;
38147
+ used.add(index);
37902
38148
  }
37903
38149
  }
37904
38150
  const quoted = QUOTED_EQUALITY.exec(detail);
@@ -37950,7 +38196,7 @@ function orphansPrinted(printed) {
37950
38196
  suites
37951
38197
  };
37952
38198
  }
37953
- var MalformedReportError, ENTITIES, ATTRIBUTE, NOTHING_RAN, NO_SUCH_PATH, DISCOVERY_FAILED = "Script errors were detected during test discovery!", ESCAPE, LOCATION = `'[^'\\n]*' in \\S+:\\d+`, IGNORING_CASE = " \\(ignoring case\\)", PRINTED_ACTUAL, QUOTED_EQUALITY, COLOUR2, SPELLED, BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]", VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]', RENDERED_AWAY_NOTE = '[gdharness: the two sides print alike, so what tells them apart is something gdUnit4 does not print. The usual cause is BBCode in the expected string, which gdUnit4 renders as formatting instead of showing; the value after "but was" is the one the assertion found.]', MASKED_BRACKET = "[lb]", PRINTED_DICTIONARY, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
38199
+ var MalformedReportError, ENTITIES, ATTRIBUTE, HOOK_FAILURE, NOTHING_RAN, NO_SUCH_PATH, DISCOVERY_FAILED = "Script errors were detected during test discovery!", ESCAPE, LOCATION = `'[^'\\n]*' in \\S+:\\d+`, IGNORING_CASE = " \\(ignoring case\\)", PRINTED_ACTUAL, QUOTED_EQUALITY, COLOUR2, SPELLED, BOTH_SIDES_ALIKE_NOTE = "[gdharness: the expected and the actual printed alike because gdUnit4 drops the marks that tell them apart when it writes its report, and the runner printed no copy of this one, so neither side above is the value the assertion found.]", VALUE_NOT_RECOVERED_NOTE = '[gdharness: the value after "but was" may not be the one the assertion found. gdUnit4 writes a failing string into its report as a character diff against the expected one with the marks dropped, which merges the two, and the runner printed no copy of this one to read the value back from.]', RENDERED_AWAY_NOTE = '[gdharness: the two sides print alike, so what tells them apart is something gdUnit4 does not print. The usual cause is BBCode in the expected string, which gdUnit4 renders as formatting instead of showing; the value after "but was" is the one the assertion found.]', MASKED_BRACKET = "[lb]", PRINTED_DICTIONARY, RUNNING_SUITE, ORPHANS, OVERALL = "Overall Summary";
37954
38200
  var init_junit = __esm(() => {
37955
38201
  MalformedReportError = class MalformedReportError extends Error {
37956
38202
  constructor(message) {
@@ -37960,8 +38206,9 @@ var init_junit = __esm(() => {
37960
38206
  };
37961
38207
  ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
37962
38208
  ATTRIBUTE = /\s*([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/y;
38209
+ HOOK_FAILURE = /^\s*(.*?)\s*\tat '(before|after)' in (res:\/\/.+?):(\d+)\s*$/;
37963
38210
  NOTHING_RAN = /no test cases found/i;
37964
- NO_SUCH_PATH = /given directory or file does not exists:\s*(\S+)/i;
38211
+ NO_SUCH_PATH = /given directory or file does not exists:\s*(.+?)\s*$/i;
37965
38212
  ESCAPE = String.fromCharCode(27);
37966
38213
  PRINTED_ACTUAL = new RegExp(` but was\\n '((?:(?!\\tat ${LOCATION})[\\s\\S])*?)'((?:${ESCAPE}\\[[0-9;]*m)*)(${IGNORING_CASE})?((?:${ESCAPE}\\[[0-9;]*m)*)\\tat (${LOCATION})`, "g");
37967
38214
  QUOTED_EQUALITY = new RegExp(`^Expecting(?: error message)?:\\n '([\\s\\S]*)'\\n but was\\n '([\\s\\S]*)'(?:${IGNORING_CASE})?\\n\\tat `);
@@ -37979,15 +38226,15 @@ var init_junit = __esm(() => {
37979
38226
  "<DEL>": ""
37980
38227
  };
37981
38228
  PRINTED_DICTIONARY = /^\{\n\t[\s\S]*\n {2}\}$/;
37982
- RUNNING_SUITE = /Run Test Suite:\s*(\S+)/;
38229
+ RUNNING_SUITE = /Run Test Suite:\s*(res:\/\/.+?\.(?:gd|cs))(?=\s|$)/;
37983
38230
  ORPHANS = /(\d+)\s+orphans/;
37984
38231
  });
37985
38232
 
37986
38233
  // src/lsp_client.ts
37987
- import { realpathSync as realpathSync2 } from "node:fs";
38234
+ import { existsSync as existsSync15, realpathSync as realpathSync4 } from "node:fs";
37988
38235
  import { readFile, realpath } from "node:fs/promises";
37989
38236
  import { createConnection as createConnection3 } from "node:net";
37990
- import { dirname as dirname12, resolve as resolve4 } from "node:path";
38237
+ import { dirname as dirname13, join as join19, resolve as resolve4 } from "node:path";
37991
38238
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
37992
38239
  function diagnosticsKey(uri) {
37993
38240
  let decoded;
@@ -38000,7 +38247,7 @@ function diagnosticsKey(uri) {
38000
38247
  return decoded;
38001
38248
  }
38002
38249
  try {
38003
- return realpathSync2.native(fileURLToPath3(decoded)).toLowerCase();
38250
+ return realpathSync4.native(fileURLToPath3(decoded)).toLowerCase();
38004
38251
  } catch {
38005
38252
  return decoded.toLowerCase();
38006
38253
  }
@@ -38017,8 +38264,10 @@ class GodotLSPClient {
38017
38264
  connectPromise = null;
38018
38265
  initialized = false;
38019
38266
  rootPath = null;
38267
+ servedWorkspace = null;
38020
38268
  diagnosticsWaiters = new Map;
38021
38269
  documentVersions = new Map;
38270
+ documentTurns = new Map;
38022
38271
  constructor(port = portFromEnv("GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT), host = "127.0.0.1") {
38023
38272
  this.port = port;
38024
38273
  this.host = host;
@@ -38115,7 +38364,7 @@ class GodotLSPClient {
38115
38364
  if (this.initialized) {
38116
38365
  return;
38117
38366
  }
38118
- const rootPath = this.rootPath ?? dirname12(resolve4(filePath));
38367
+ const rootPath = this.rootPath ?? dirname13(resolve4(filePath));
38119
38368
  await this.initialize(rootPath);
38120
38369
  }
38121
38370
  async sendRequest(method, params) {
@@ -38190,6 +38439,10 @@ class GodotLSPClient {
38190
38439
  }
38191
38440
  }
38192
38441
  }
38442
+ if (message["method"] === "gdscript_client/changeWorkspace") {
38443
+ const path = message["params"]?.["path"];
38444
+ this.servedWorkspace = typeof path === "string" ? path : null;
38445
+ }
38193
38446
  if (message["method"] === "textDocument/publishDiagnostics") {
38194
38447
  const params = message["params"];
38195
38448
  const paramsObject = params && typeof params === "object" ? params : null;
@@ -38292,10 +38545,14 @@ class GodotLSPClient {
38292
38545
  this.sendNotification("textDocument/didClose", { textDocument: { uri } });
38293
38546
  } catch {}
38294
38547
  }
38548
+ forgetServedWorkspace() {
38549
+ this.servedWorkspace = null;
38550
+ }
38295
38551
  async initialize(rootPath) {
38296
38552
  await this.ensureConnected();
38297
38553
  const resolvedRootPath = resolve4(rootPath);
38298
38554
  const rootUri = pathToFileURL(resolvedRootPath).href;
38555
+ this.forgetServedWorkspace();
38299
38556
  const result = await this.sendRequest("initialize", {
38300
38557
  processId: process.pid,
38301
38558
  rootPath: resolvedRootPath,
@@ -38321,60 +38578,82 @@ class GodotLSPClient {
38321
38578
  }
38322
38579
  ]
38323
38580
  });
38581
+ const served = this.servedWorkspace;
38582
+ if (served !== null && !isSameDirectory(served, resolvedRootPath)) {
38583
+ throw new AnotherProjectsServer(this.port, served, resolvedRootPath);
38584
+ }
38324
38585
  this.sendNotification("initialized", {});
38325
38586
  this.initialized = true;
38326
38587
  this.rootPath = resolvedRootPath;
38327
38588
  return result;
38328
38589
  }
38329
- async getDiagnostics(filePath, content) {
38330
- await this.ensureConnected();
38331
- await this.ensureInitializedForFile(filePath);
38590
+ async withDocument(filePath, work) {
38332
38591
  const key = diagnosticsKey(this.toFileUri(filePath));
38333
- const diagnosticsPromise = new Promise((resolveDiagnostics, rejectDiagnostics) => {
38334
- const existing = this.diagnosticsWaiters.get(key);
38335
- if (existing) {
38336
- clearTimeout(existing.timer);
38337
- }
38338
- const timer = setTimeout(() => {
38339
- this.diagnosticsWaiters.delete(key);
38340
- rejectDiagnostics(new Error(`Godot published no diagnostics for ${key} within ${DIAGNOSTICS_TIMEOUT_MS}ms. ` + "The language server may not be running, or may not have this file in its workspace."));
38341
- }, DIAGNOSTICS_TIMEOUT_MS);
38342
- this.diagnosticsWaiters.set(key, {
38343
- resolve: resolveDiagnostics,
38344
- reject: rejectDiagnostics,
38345
- timer
38346
- });
38592
+ const before = this.documentTurns.get(key) ?? Promise.resolve();
38593
+ let done = () => {};
38594
+ const mine = new Promise((finished) => {
38595
+ done = finished;
38347
38596
  });
38348
- let opened = null;
38597
+ const turn = before.then(() => mine);
38598
+ this.documentTurns.set(key, turn);
38599
+ await before;
38349
38600
  try {
38350
- opened = this.syncDocument(filePath, content);
38351
- return await diagnosticsPromise;
38352
- } catch (error) {
38353
- const waiter = this.diagnosticsWaiters.get(key);
38354
- if (waiter) {
38355
- clearTimeout(waiter.timer);
38356
- this.diagnosticsWaiters.delete(key);
38357
- }
38358
- throw error;
38601
+ return await work();
38359
38602
  } finally {
38360
- if (opened !== null) {
38361
- this.closeDocument(opened);
38603
+ done();
38604
+ if (this.documentTurns.get(key) === turn) {
38605
+ this.documentTurns.delete(key);
38362
38606
  }
38363
38607
  }
38364
38608
  }
38365
- async getCompletions(filePath, content, line, character) {
38609
+ async getDiagnostics(filePath, content) {
38366
38610
  await this.ensureConnected();
38367
38611
  await this.ensureInitializedForFile(filePath);
38368
- const uri = this.syncDocument(filePath, content);
38369
- let result;
38370
- try {
38371
- result = await this.sendRequest("textDocument/completion", {
38372
- textDocument: { uri },
38373
- position: { line, character }
38612
+ return this.withDocument(filePath, async () => {
38613
+ const key = diagnosticsKey(this.toFileUri(filePath));
38614
+ const diagnosticsPromise = new Promise((resolveDiagnostics, rejectDiagnostics) => {
38615
+ const timer = setTimeout(() => {
38616
+ this.diagnosticsWaiters.delete(key);
38617
+ rejectDiagnostics(new Error(`Godot published no diagnostics for ${key} within ${DIAGNOSTICS_TIMEOUT_MS}ms. ` + "The language server may not be running, or may not have this file in its workspace."));
38618
+ }, DIAGNOSTICS_TIMEOUT_MS);
38619
+ this.diagnosticsWaiters.set(key, {
38620
+ resolve: resolveDiagnostics,
38621
+ reject: rejectDiagnostics,
38622
+ timer
38623
+ });
38374
38624
  });
38375
- } finally {
38376
- this.closeDocument(uri);
38377
- }
38625
+ let opened = null;
38626
+ try {
38627
+ opened = this.syncDocument(filePath, content);
38628
+ return await diagnosticsPromise;
38629
+ } catch (error) {
38630
+ const waiter = this.diagnosticsWaiters.get(key);
38631
+ if (waiter) {
38632
+ clearTimeout(waiter.timer);
38633
+ this.diagnosticsWaiters.delete(key);
38634
+ }
38635
+ throw error;
38636
+ } finally {
38637
+ if (opened !== null) {
38638
+ this.closeDocument(opened);
38639
+ }
38640
+ }
38641
+ });
38642
+ }
38643
+ async getCompletions(filePath, content, line, character) {
38644
+ await this.ensureConnected();
38645
+ await this.ensureInitializedForFile(filePath);
38646
+ const result = await this.withDocument(filePath, async () => {
38647
+ const uri = this.syncDocument(filePath, content);
38648
+ try {
38649
+ return await this.sendRequest("textDocument/completion", {
38650
+ textDocument: { uri },
38651
+ position: { line, character }
38652
+ });
38653
+ } finally {
38654
+ this.closeDocument(uri);
38655
+ }
38656
+ });
38378
38657
  if (Array.isArray(result)) {
38379
38658
  return result;
38380
38659
  }
@@ -38389,28 +38668,31 @@ class GodotLSPClient {
38389
38668
  async getHover(filePath, content, line, character) {
38390
38669
  await this.ensureConnected();
38391
38670
  await this.ensureInitializedForFile(filePath);
38392
- const uri = this.syncDocument(filePath, content);
38393
- try {
38394
- return await this.sendRequest("textDocument/hover", {
38395
- textDocument: { uri },
38396
- position: { line, character }
38397
- });
38398
- } finally {
38399
- this.closeDocument(uri);
38400
- }
38671
+ return this.withDocument(filePath, async () => {
38672
+ const uri = this.syncDocument(filePath, content);
38673
+ try {
38674
+ return await this.sendRequest("textDocument/hover", {
38675
+ textDocument: { uri },
38676
+ position: { line, character }
38677
+ });
38678
+ } finally {
38679
+ this.closeDocument(uri);
38680
+ }
38681
+ });
38401
38682
  }
38402
38683
  async getDocumentSymbols(filePath, content) {
38403
38684
  await this.ensureConnected();
38404
38685
  await this.ensureInitializedForFile(filePath);
38405
- const uri = this.syncDocument(filePath, content);
38406
- let result;
38407
- try {
38408
- result = await this.sendRequest("textDocument/documentSymbol", {
38409
- textDocument: { uri }
38410
- });
38411
- } finally {
38412
- this.closeDocument(uri);
38413
- }
38686
+ const result = await this.withDocument(filePath, async () => {
38687
+ const uri = this.syncDocument(filePath, content);
38688
+ try {
38689
+ return await this.sendRequest("textDocument/documentSymbol", {
38690
+ textDocument: { uri }
38691
+ });
38692
+ } finally {
38693
+ this.closeDocument(uri);
38694
+ }
38695
+ });
38414
38696
  return Array.isArray(result) ? result : [];
38415
38697
  }
38416
38698
  isConnected() {
@@ -38442,36 +38724,39 @@ async function resolveLSPPaths(projectPathValue, scriptPathValue) {
38442
38724
  try {
38443
38725
  projectPath = await realpath(requestedProjectPath);
38444
38726
  } catch {
38445
- throw new Refusal(`Project path does not exist: ${requestedProjectPath}`);
38727
+ throw new ArgumentRefusal(`Project path does not exist: ${requestedProjectPath}`);
38728
+ }
38729
+ if (!existsSync15(join19(projectPath, "project.godot"))) {
38730
+ throw new ArgumentRefusal(`Not a Godot project: ${projectPath}. Point projectPath at the directory holding project.godot.`);
38446
38731
  }
38447
38732
  const contained = resolveWithinProject(projectPath, scriptPathValue);
38448
38733
  if (!contained.ok) {
38449
- throw new Refusal(contained.reason);
38734
+ throw new ArgumentRefusal(contained.reason);
38450
38735
  }
38451
38736
  let scriptPath;
38452
38737
  try {
38453
38738
  scriptPath = await realpath(contained.absolutePath);
38454
38739
  } catch {
38455
- throw new Refusal(`Script file does not exist: ${contained.absolutePath}`);
38740
+ throw new ArgumentRefusal(`Script file does not exist: ${contained.absolutePath}`);
38456
38741
  }
38457
38742
  if (!isWithinRoot(projectPath, scriptPath)) {
38458
- throw new Refusal("scriptPath resolves outside the project root boundary.");
38743
+ throw new ArgumentRefusal("scriptPath resolves outside the project root boundary.");
38459
38744
  }
38460
38745
  return { projectPath, scriptPath };
38461
38746
  }
38462
38747
  async function handleLSPTool(client, toolName, args) {
38463
38748
  try {
38464
38749
  if (!args || typeof args !== "object") {
38465
- throw new Refusal("Tool arguments must be an object.");
38750
+ throw new ArgumentRefusal("Tool arguments must be an object.");
38466
38751
  }
38467
38752
  const parsedArgs = args;
38468
38753
  const projectPathValue = parsedArgs["projectPath"];
38469
38754
  const scriptPathValue = parsedArgs["scriptPath"];
38470
38755
  if (typeof projectPathValue !== "string" || projectPathValue.length === 0) {
38471
- throw new Refusal("Missing required argument: projectPath");
38756
+ throw new ArgumentRefusal("Missing required argument: projectPath");
38472
38757
  }
38473
38758
  if (typeof scriptPathValue !== "string" || scriptPathValue.length === 0) {
38474
- throw new Refusal("Missing required argument: scriptPath");
38759
+ throw new ArgumentRefusal("Missing required argument: scriptPath");
38475
38760
  }
38476
38761
  const { projectPath, scriptPath } = await resolveLSPPaths(projectPathValue, scriptPathValue);
38477
38762
  const content = await readFile(scriptPath, "utf8");
@@ -38485,7 +38770,7 @@ async function handleLSPTool(client, toolName, args) {
38485
38770
  const line = Number(parsedArgs["line"]);
38486
38771
  const character = Number(parsedArgs["character"]);
38487
38772
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
38488
- throw new Refusal("Arguments line and character must be numbers.");
38773
+ throw new ArgumentRefusal("Arguments line and character must be numbers.");
38489
38774
  }
38490
38775
  const completions = await client.getCompletions(scriptPath, content, line, character);
38491
38776
  return asToolResponse({ completions });
@@ -38494,7 +38779,7 @@ async function handleLSPTool(client, toolName, args) {
38494
38779
  const line = Number(parsedArgs["line"]);
38495
38780
  const character = Number(parsedArgs["character"]);
38496
38781
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
38497
- throw new Refusal("Arguments line and character must be numbers.");
38782
+ throw new ArgumentRefusal("Arguments line and character must be numbers.");
38498
38783
  }
38499
38784
  const hover = await client.getHover(scriptPath, content, line, character);
38500
38785
  return asToolResponse({ hover });
@@ -38508,20 +38793,31 @@ async function handleLSPTool(client, toolName, args) {
38508
38793
  }
38509
38794
  } catch (error) {
38510
38795
  return asToolResponse({
38511
- error: normalizeLSPError(error, client.port)
38796
+ error: normalizeLSPError(error, client.port),
38797
+ ...error instanceof AnotherProjectsServer ? { serves: error.serves } : {},
38798
+ ...error instanceof ArgumentRefusal ? { refusedArguments: true } : {}
38512
38799
  });
38513
38800
  }
38514
38801
  }
38515
- var DEFAULT_LSP_PORT = 6005, DIAGNOSTICS_TIMEOUT_MS = 5000;
38802
+ var DEFAULT_LSP_PORT = 6005, DIAGNOSTICS_TIMEOUT_MS = 5000, ArgumentRefusal, AnotherProjectsServer;
38516
38803
  var init_lsp_client = __esm(() => {
38517
38804
  init_errors();
38518
38805
  init_framing();
38519
38806
  init_paths();
38520
38807
  init_ports();
38808
+ ArgumentRefusal = class ArgumentRefusal extends Refusal {
38809
+ };
38810
+ AnotherProjectsServer = class AnotherProjectsServer extends Refusal {
38811
+ serves;
38812
+ constructor(port, serves, asked) {
38813
+ super(`The language server on port ${port} belongs to the editor of ${serves}, not ${asked}.`);
38814
+ this.serves = serves;
38815
+ }
38816
+ };
38521
38817
  });
38522
38818
 
38523
38819
  // src/outside.ts
38524
- import { spawn as spawn2 } from "node:child_process";
38820
+ import { spawn as spawn3 } from "node:child_process";
38525
38821
  import { fileURLToPath as fileURLToPath4 } from "node:url";
38526
38822
  function changesFrom(env, base) {
38527
38823
  const changes = {};
@@ -38554,7 +38850,7 @@ function readLaunched(printed) {
38554
38850
  }
38555
38851
  async function launchOutsideTheTree(spec, runtime = process.execPath, keeper = KEEPER_SCRIPT) {
38556
38852
  return await new Promise((resolve) => {
38557
- const launcher = spawn2(runtime, [keeper, "launch"], {
38853
+ const launcher = spawn3(runtime, [keeper, "launch"], {
38558
38854
  stdio: ["pipe", "pipe", "pipe"],
38559
38855
  windowsHide: true
38560
38856
  });
@@ -38589,8 +38885,8 @@ var init_outside = __esm(() => {
38589
38885
  });
38590
38886
 
38591
38887
  // src/process-time.ts
38592
- import { execFile as execFile4 } from "node:child_process";
38593
- import { promisify as promisify4 } from "node:util";
38888
+ import { execFile as execFile3 } from "node:child_process";
38889
+ import { promisify as promisify3 } from "node:util";
38594
38890
  async function cpuSecondsOf(pid) {
38595
38891
  try {
38596
38892
  return process.platform === "win32" ? await secondsOnWindows(pid) : await askPosix2(pid);
@@ -38604,7 +38900,7 @@ async function secondsOnWindows(pid) {
38604
38900
  return Number.isFinite(seconds) ? seconds : undefined;
38605
38901
  }
38606
38902
  async function askPosix2(pid) {
38607
- const { stdout } = await run4("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
38903
+ const { stdout } = await run3("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
38608
38904
  return secondsFromClock(stdout.trim());
38609
38905
  }
38610
38906
  function secondsFromClock(printed) {
@@ -38622,20 +38918,20 @@ function secondsFromClock(printed) {
38622
38918
  const extra = days === null ? 0 : Number(days) * 86400;
38623
38919
  return Number.isFinite(extra) ? seconds + extra : undefined;
38624
38920
  }
38625
- var run4, ASK_TIMEOUT_MS2 = 6000;
38921
+ var run3, ASK_TIMEOUT_MS2 = 6000;
38626
38922
  var init_process_time = __esm(() => {
38627
38923
  init_windows_ask();
38628
- run4 = promisify4(execFile4);
38924
+ run3 = promisify3(execFile3);
38629
38925
  });
38630
38926
 
38631
38927
  // src/project-scan.ts
38632
- import { existsSync as existsSync14, readdirSync as readdirSync7, readFileSync as readFileSync16 } from "node:fs";
38633
- import { join as join19 } from "node:path";
38928
+ import { existsSync as existsSync16, readdirSync as readdirSync7, readFileSync as readFileSync17 } from "node:fs";
38929
+ import { join as join20 } from "node:path";
38634
38930
  function skipped(name) {
38635
38931
  return name.startsWith(".") || SKIPPED.has(name);
38636
38932
  }
38637
38933
  function steppedOver(directory) {
38638
- return existsSync14(join19(directory, ".gdignore"));
38934
+ return existsSync16(join20(directory, ".gdignore"));
38639
38935
  }
38640
38936
  function projectStructure(projectPath) {
38641
38937
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
@@ -38648,10 +38944,10 @@ function projectStructure(projectPath) {
38648
38944
  continue;
38649
38945
  }
38650
38946
  if (entry.isDirectory()) {
38651
- visit(join19(directory, entry.name));
38947
+ visit(join20(directory, entry.name));
38652
38948
  } else if (entry.isFile()) {
38653
38949
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
38654
- if (extension === "tscn") {
38950
+ if (SCENE_EXTENSIONS.has(extension)) {
38655
38951
  structure.scenes += 1;
38656
38952
  } else if (extension === "gd" || extension === "cs") {
38657
38953
  structure.scripts += 1;
@@ -38678,14 +38974,14 @@ function scriptsWithoutUid(projectPath) {
38678
38974
  }
38679
38975
  const spelled = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
38680
38976
  if (entry.isDirectory()) {
38681
- visit(join19(directory, entry.name), spelled);
38977
+ visit(join20(directory, entry.name), spelled);
38682
38978
  continue;
38683
38979
  }
38684
38980
  if (!entry.isFile()) {
38685
38981
  continue;
38686
38982
  }
38687
38983
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
38688
- if (CARRIES_A_UID.has(extension) && !existsSync14(join19(directory, `${entry.name}.uid`))) {
38984
+ if (CARRIES_A_UID.has(extension) && !existsSync16(join20(directory, `${entry.name}.uid`))) {
38689
38985
  found.push(spelled);
38690
38986
  }
38691
38987
  }
@@ -38693,8 +38989,11 @@ function scriptsWithoutUid(projectPath) {
38693
38989
  visit(projectPath, "");
38694
38990
  return found.sort();
38695
38991
  }
38992
+ function isBinary(bytes) {
38993
+ return bytes.subarray(0, 8000).includes(0);
38994
+ }
38696
38995
  function searchProject(projectPath, options) {
38697
- const extensions = new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
38996
+ const extensions = options.fileTypes === undefined ? null : new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
38698
38997
  const result = {
38699
38998
  query: options.query,
38700
38999
  results: [],
@@ -38702,13 +39001,7 @@ function searchProject(projectPath, options) {
38702
39001
  };
38703
39002
  const regex = options.regex ? new RegExp(options.query, options.caseSensitive ? "" : "i") : null;
38704
39003
  const needle = options.caseSensitive ? options.query : options.query.toLowerCase();
38705
- const full = () => {
38706
- if (result.summary.total_matches >= options.maxResults) {
38707
- result.summary.truncated = true;
38708
- return true;
38709
- }
38710
- return false;
38711
- };
39004
+ const full = () => result.summary.truncated;
38712
39005
  const visit = (directory) => {
38713
39006
  if (steppedOver(directory)) {
38714
39007
  return;
@@ -38720,27 +39013,32 @@ function searchProject(projectPath, options) {
38720
39013
  if (skipped(entry.name)) {
38721
39014
  continue;
38722
39015
  }
38723
- const entryPath = join19(directory, entry.name);
39016
+ const entryPath = join20(directory, entry.name);
38724
39017
  if (entry.isDirectory()) {
38725
39018
  visit(entryPath);
38726
39019
  continue;
38727
39020
  }
38728
39021
  const extension = entry.name.includes(".") ? entry.name.split(".").pop()?.toLowerCase() ?? "" : "";
38729
- if (!entry.isFile() || !extensions.has(extension)) {
39022
+ if (!entry.isFile() || extensions !== null && !extensions.has(extension)) {
39023
+ continue;
39024
+ }
39025
+ const bytes = readFileSync17(entryPath);
39026
+ if (extensions === null && isBinary(bytes)) {
38730
39027
  continue;
38731
39028
  }
38732
39029
  result.summary.files_searched += 1;
38733
39030
  const matches = [];
38734
- for (const [index, line] of readFileSync16(entryPath, "utf8").split(`
38735
- `).entries()) {
38736
- if (full()) {
38737
- break;
39031
+ for (const [index, line] of bytes.toString("utf8").split(/\r?\n/).entries()) {
39032
+ const found = regex ? regex.exec(line) : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? [options.query] : null;
39033
+ if (found === null) {
39034
+ continue;
38738
39035
  }
38739
- const match = regex ? regex.exec(line)?.[0] : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? options.query : "";
38740
- if (match) {
38741
- matches.push({ line: index + 1, content: line.trim(), match });
38742
- result.summary.total_matches += 1;
39036
+ if (result.summary.total_matches >= options.maxResults) {
39037
+ result.summary.truncated = true;
39038
+ break;
38743
39039
  }
39040
+ matches.push({ line: index + 1, content: line.trim(), match: found[0] ?? "" });
39041
+ result.summary.total_matches += 1;
38744
39042
  }
38745
39043
  if (matches.length > 0) {
38746
39044
  const relativePath = entryPath.slice(projectPath.length + 1).replace(/\\/g, "/");
@@ -38752,23 +39050,53 @@ function searchProject(projectPath, options) {
38752
39050
  visit(projectPath);
38753
39051
  return result;
38754
39052
  }
38755
- var SKIPPED, ASSET_EXTENSIONS, CARRIES_A_UID;
39053
+ var SKIPPED, SCENE_EXTENSIONS, ASSET_EXTENSIONS, CARRIES_A_UID;
38756
39054
  var init_project_scan = __esm(() => {
38757
39055
  SKIPPED = new Set(["node_modules"]);
38758
- ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
39056
+ SCENE_EXTENSIONS = new Set(["tscn", "scn"]);
39057
+ ASSET_EXTENSIONS = new Set([
39058
+ "png",
39059
+ "jpg",
39060
+ "jpeg",
39061
+ "webp",
39062
+ "svg",
39063
+ "bmp",
39064
+ "tga",
39065
+ "exr",
39066
+ "hdr",
39067
+ "ktx",
39068
+ "dds",
39069
+ "ttf",
39070
+ "otf",
39071
+ "woff",
39072
+ "woff2",
39073
+ "fnt",
39074
+ "wav",
39075
+ "mp3",
39076
+ "ogg",
39077
+ "glb",
39078
+ "gltf",
39079
+ "obj",
39080
+ "fbx",
39081
+ "blend",
39082
+ "dae",
39083
+ "ogv",
39084
+ "gdshader",
39085
+ "shader"
39086
+ ]);
38759
39087
  CARRIES_A_UID = new Set(["gd", "shader", "gdshader"]);
38760
39088
  });
38761
39089
 
38762
39090
  // src/restart-note.ts
38763
- import { mkdirSync as mkdirSync10, readFileSync as readFileSync17, rmSync as rmSync9, writeFileSync as writeFileSync12 } from "node:fs";
38764
- import { dirname as dirname13, join as join20 } from "node:path";
39091
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync18, rmSync as rmSync9, writeFileSync as writeFileSync12 } from "node:fs";
39092
+ import { dirname as dirname14, join as join21 } from "node:path";
38765
39093
  function restartNotePath(projectPath) {
38766
- return join20(projectPath, ".godot", "gdharness-restart.json");
39094
+ return join21(projectPath, ".godot", "gdharness-restart.json");
38767
39095
  }
38768
39096
  function noteRestartBegun(note) {
38769
39097
  const path = restartNotePath(note.projectPath);
38770
39098
  try {
38771
- mkdirSync10(dirname13(path), { recursive: true });
39099
+ mkdirSync10(dirname14(path), { recursive: true });
38772
39100
  writeFileSync12(path, `${JSON.stringify(note, null, 2)}
38773
39101
  `, "utf8");
38774
39102
  } catch (error) {
@@ -38777,7 +39105,7 @@ function noteRestartBegun(note) {
38777
39105
  }
38778
39106
  function restartOwed(projectPath) {
38779
39107
  try {
38780
- const parsed = JSON.parse(readFileSync17(restartNotePath(projectPath), "utf8"));
39108
+ const parsed = JSON.parse(readFileSync18(restartNotePath(projectPath), "utf8"));
38781
39109
  if (typeof parsed !== "object" || parsed === null) {
38782
39110
  return null;
38783
39111
  }
@@ -38802,31 +39130,31 @@ var init_restart_note = __esm(() => {
38802
39130
  });
38803
39131
 
38804
39132
  // src/run-record.ts
38805
- import { execFile as execFile5 } from "node:child_process";
39133
+ import { execFile as execFile4 } from "node:child_process";
38806
39134
  import { createHash as createHash4 } from "node:crypto";
38807
39135
  import {
38808
39136
  mkdirSync as mkdirSync11,
38809
39137
  openSync as openSync2,
38810
39138
  readdirSync as readdirSync8,
38811
- readFileSync as readFileSync18,
38812
- realpathSync as realpathSync3,
39139
+ readFileSync as readFileSync19,
39140
+ realpathSync as realpathSync5,
38813
39141
  rmSync as rmSync10,
38814
39142
  statSync as statSync7,
38815
39143
  writeFileSync as writeFileSync13
38816
39144
  } from "node:fs";
38817
- import { basename as basename4, dirname as dirname14, join as join21, resolve as resolve5 } from "node:path";
38818
- import { promisify as promisify5 } from "node:util";
39145
+ import { basename as basename4, dirname as dirname15, join as join22, resolve as resolve5 } from "node:path";
39146
+ import { promisify as promisify4 } from "node:util";
38819
39147
  function runsDirectory() {
38820
- return join21(runtimeDirectory(), "runs");
39148
+ return join22(runtimeDirectory(), "runs");
38821
39149
  }
38822
39150
  function realAsFarAsItExists(path) {
38823
39151
  const missing = [];
38824
39152
  let at = path;
38825
39153
  for (;; ) {
38826
39154
  try {
38827
- return join21(realpathSync3.native(at), ...missing);
39155
+ return join22(realpathSync5.native(at), ...missing);
38828
39156
  } catch {
38829
- const parent = dirname14(at);
39157
+ const parent = dirname15(at);
38830
39158
  if (parent === at) {
38831
39159
  return path;
38832
39160
  }
@@ -38841,15 +39169,18 @@ function projectKey(projectPath) {
38841
39169
  return createHash4("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
38842
39170
  }
38843
39171
  function runRecordPath(projectPath, directory = runtimeDirectory()) {
38844
- return join21(directory, "runs", `note-${projectKey(projectPath)}.json`);
39172
+ return join22(directory, "runs", `note-${projectKey(projectPath)}.json`);
38845
39173
  }
38846
39174
  function openTranscript(startedAt) {
38847
39175
  mkdirSync11(runsDirectory(), { recursive: true });
38848
- const path = join21(runsDirectory(), `run-${startedAt}.log`);
39176
+ const path = join22(runsDirectory(), `run-${startedAt}.log`);
38849
39177
  return { path, fd: openSync2(path, "a") };
38850
39178
  }
39179
+ function noteIsAbout(note, playing) {
39180
+ return note.play !== undefined && playing !== null && note.play.play === playing.play && note.play.editorPid === playing.editorPid;
39181
+ }
38851
39182
  function editorNotePath(projectPath, directory = runtimeDirectory()) {
38852
- return join21(directory, "runs", `editor-${projectKey(projectPath)}.json`);
39183
+ return join22(directory, "runs", `editor-${projectKey(projectPath)}.json`);
38853
39184
  }
38854
39185
  function writeEditorRunNote(note) {
38855
39186
  mkdirSync11(runsDirectory(), { recursive: true });
@@ -38857,7 +39188,7 @@ function writeEditorRunNote(note) {
38857
39188
  }
38858
39189
  function readEditorRunNote(projectPath) {
38859
39190
  for (const directory of runtimeDirectories()) {
38860
- const note = editorNoteAt(editorNotePath(projectPath, directory)) ?? editorNoteAt(join21(directory, "runs", "editor-run.json"));
39191
+ const note = editorNoteAt(editorNotePath(projectPath, directory)) ?? editorNoteAt(join22(directory, "runs", "editor-run.json"));
38861
39192
  if (note !== null && isSameDirectory(note.projectPath, projectPath)) {
38862
39193
  return note;
38863
39194
  }
@@ -38867,7 +39198,7 @@ function readEditorRunNote(projectPath) {
38867
39198
  function editorNoteAt(path) {
38868
39199
  let parsed;
38869
39200
  try {
38870
- parsed = JSON.parse(readFileSync18(path, "utf8"));
39201
+ parsed = JSON.parse(readFileSync19(path, "utf8"));
38871
39202
  } catch {
38872
39203
  return null;
38873
39204
  }
@@ -38881,7 +39212,9 @@ function editorNoteAt(path) {
38881
39212
  if (typeof projectPath !== "string" || typeof transcript !== "string" || typeof startedAt !== "number") {
38882
39213
  return null;
38883
39214
  }
38884
- return { projectPath, transcript, startedAt };
39215
+ const play = fields["play"];
39216
+ const which = typeof play === "object" && play !== null && typeof play["play"] === "number" && typeof play["editorPid"] === "number" ? play : undefined;
39217
+ return { projectPath, transcript, startedAt, ...which === undefined ? {} : { play: which } };
38885
39218
  }
38886
39219
  function readRunRecord(projectPath) {
38887
39220
  for (const directory of runtimeDirectories()) {
@@ -38889,7 +39222,7 @@ function readRunRecord(projectPath) {
38889
39222
  if (own !== null) {
38890
39223
  return own;
38891
39224
  }
38892
- const shared = recordAt(join21(directory, "runs", SHARED_NOTE));
39225
+ const shared = recordAt(join22(directory, "runs", SHARED_NOTE));
38893
39226
  if (shared !== null && shared.projectPath !== "" && isSameDirectory(shared.projectPath, projectPath)) {
38894
39227
  return shared;
38895
39228
  }
@@ -38901,13 +39234,13 @@ function everyRunRecord() {
38901
39234
  for (const directory of runtimeDirectories()) {
38902
39235
  let entries;
38903
39236
  try {
38904
- entries = readdirSync8(join21(directory, "runs"));
39237
+ entries = readdirSync8(join22(directory, "runs"));
38905
39238
  } catch {
38906
39239
  continue;
38907
39240
  }
38908
39241
  for (const entry of entries) {
38909
39242
  if (entry === SHARED_NOTE || entry.startsWith("note-") && entry.endsWith(".json")) {
38910
- const record = recordAt(join21(directory, "runs", entry));
39243
+ const record = recordAt(join22(directory, "runs", entry));
38911
39244
  if (record !== null) {
38912
39245
  found.push(record);
38913
39246
  }
@@ -38919,7 +39252,7 @@ function everyRunRecord() {
38919
39252
  function recordAt(path) {
38920
39253
  let parsed;
38921
39254
  try {
38922
- parsed = JSON.parse(readFileSync18(path, "utf8"));
39255
+ parsed = JSON.parse(readFileSync19(path, "utf8"));
38923
39256
  } catch {
38924
39257
  return null;
38925
39258
  }
@@ -38938,7 +39271,9 @@ function recordAt(path) {
38938
39271
  pid,
38939
39272
  transcript,
38940
39273
  startedAt,
39274
+ ...typeof fields["startedBy"] === "number" ? { startedBy: fields["startedBy"] } : {},
38941
39275
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
39276
+ ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
38942
39277
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
38943
39278
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
38944
39279
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -38947,7 +39282,8 @@ function recordAt(path) {
38947
39282
  }
38948
39283
  async function runningAs(pid) {
38949
39284
  if (process.platform === "win32") {
38950
- const answer = await windowsCommandLine(pid);
39285
+ const read = await readWindowsCommandLine(pid);
39286
+ const answer = "answer" in read ? read.answer : null;
38951
39287
  if (answer !== null) {
38952
39288
  const [, ...rest] = answer.split(`
38953
39289
  `);
@@ -38959,13 +39295,17 @@ async function runningAs(pid) {
38959
39295
  }
38960
39296
  }
38961
39297
  const image = await windowsImage(pid);
38962
- return image === null ? null : { kind: "image", text: image };
39298
+ return image === null ? null : {
39299
+ kind: "image",
39300
+ text: image,
39301
+ unread: "unread" in read ? read.unread : "Windows gave an empty command line"
39302
+ };
38963
39303
  }
38964
39304
  try {
38965
39305
  const began = await startedAt(pid, null);
38966
39306
  const when = began === null ? {} : { startedAt: began };
38967
39307
  if (process.platform === "linux") {
38968
- const raw = readFileSync18(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
39308
+ const raw = readFileSync19(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
38969
39309
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
38970
39310
  }
38971
39311
  const args = (await execFileAsync("ps", ["-p", String(pid), "-o", "args="], { encoding: "utf8", timeout: 15000 })).stdout.trim();
@@ -39010,13 +39350,18 @@ function listeningPidInNetstat(printed, port) {
39010
39350
  }
39011
39351
  return null;
39012
39352
  }
39013
- async function windowsCommandLine(pid) {
39014
- try {
39015
- const answer = (await askWindows(`$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`, 15000)).trim();
39016
- return answer === "" ? null : answer;
39017
- } catch {
39018
- return null;
39353
+ async function readWindowsCommandLine(pid, ask = askWindows) {
39354
+ const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`;
39355
+ let unread = "";
39356
+ for (const withinMs of [15000, 45000]) {
39357
+ try {
39358
+ const answer = (await ask(query, withinMs)).trim();
39359
+ return answer === "" ? { unread: "Windows gave no command line for it" } : { answer };
39360
+ } catch (error) {
39361
+ unread = errorMessage(error);
39362
+ }
39019
39363
  }
39364
+ return { unread };
39020
39365
  }
39021
39366
  async function startedAt(pid, windowsAnswer) {
39022
39367
  if (process.platform === "win32") {
@@ -39044,36 +39389,43 @@ async function windowsImage(pid) {
39044
39389
  return null;
39045
39390
  }
39046
39391
  }
39047
- async function stillTheRecordedRun(record) {
39048
- return judgeRun(record, await runningAs(record.pid), "confirmed");
39392
+ async function whyNotStillTheRecordedRun(record) {
39393
+ return whyNotTheRun(record, await runningAs(record.pid), "confirmed");
39049
39394
  }
39050
39395
  async function couldStillBeTheRecordedRun(record) {
39051
39396
  return judgeRun(record, await runningAs(record.pid), "possible");
39052
39397
  }
39053
39398
  function judgeRun(record, running, needed) {
39399
+ return whyNotTheRun(record, running, needed) === null;
39400
+ }
39401
+ function whyNotTheRun(record, running, needed) {
39054
39402
  if (running === null) {
39055
- return false;
39403
+ return "the operating system would not describe that process";
39056
39404
  }
39057
- if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
39058
- return false;
39405
+ const after = running.startedAt === undefined ? 0 : running.startedAt - (record.startedBy ?? record.startedAt);
39406
+ if (after > SAME_RUN_WINDOW_MS) {
39407
+ return `that process started ${Math.round(after / 1000)} seconds after the run had`;
39059
39408
  }
39060
39409
  const engine = record.command === undefined ? null : basename4(record.command);
39061
39410
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
39062
39411
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
39063
39412
  if (wanted !== null && !said.includes(wanted)) {
39064
- return false;
39413
+ return `that process is not ${engine ?? "the engine"}, which the run was started with`;
39065
39414
  }
39066
39415
  if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
39067
- return false;
39416
+ return "that process is an editor";
39068
39417
  }
39069
39418
  if (record.projectPath === "") {
39070
- return wanted !== null;
39419
+ return wanted !== null ? null : "the record names neither the project nor the engine";
39071
39420
  }
39072
39421
  if (running.kind === "image") {
39073
- return needed === "possible" && wanted !== null;
39422
+ if (wanted === null) {
39423
+ return "the operating system named only its executable, and the record names no engine";
39424
+ }
39425
+ return needed === "possible" ? null : `the operating system named only its executable and not its command line (${running.unread ?? "no reason given"}), which is not enough to signal it on`;
39074
39426
  }
39075
39427
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
39076
- return said.includes(project);
39428
+ return said.includes(project) ? null : `that process's command line does not name ${record.projectPath}`;
39077
39429
  }
39078
39430
  async function stillTheAnnouncedGame(pid, projectPath, announcedAt) {
39079
39431
  return judgeAnnouncedGame(await runningAs(pid), projectPath, announcedAt);
@@ -39093,7 +39445,7 @@ function judgeAnnouncedGame(running, projectPath, announcedAt) {
39093
39445
  }
39094
39446
  function clearRunRecord(projectPath) {
39095
39447
  for (const directory of runtimeDirectories()) {
39096
- const shared = join21(directory, "runs", SHARED_NOTE);
39448
+ const shared = join22(directory, "runs", SHARED_NOTE);
39097
39449
  const sharedRecord = recordAt(shared);
39098
39450
  const ours = sharedRecord !== null && sharedRecord.projectPath !== "" && isSameDirectory(sharedRecord.projectPath, projectPath);
39099
39451
  for (const path of [runRecordPath(projectPath, directory), ...ours ? [shared] : []]) {
@@ -39123,7 +39475,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
39123
39475
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
39124
39476
  continue;
39125
39477
  }
39126
- const path = join21(directory, entry);
39478
+ const path = join22(directory, entry);
39127
39479
  if (kept.has(path) || ageMs(path, now) < keepMs) {
39128
39480
  continue;
39129
39481
  }
@@ -39134,35 +39486,43 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
39134
39486
  }
39135
39487
  var execFileAsync, SHARED_NOTE = "run.json", SAME_RUN_WINDOW_MS = 1e4;
39136
39488
  var init_run_record = __esm(() => {
39489
+ init_errors();
39137
39490
  init_paths();
39138
39491
  init_process_children();
39139
39492
  init_runtime_client();
39140
39493
  init_windows_ask();
39141
- execFileAsync = promisify5(execFile5);
39494
+ execFileAsync = promisify4(execFile4);
39142
39495
  });
39143
39496
 
39144
39497
  // src/server.ts
39145
- import { execFile as execFile6, spawn as spawn3 } from "node:child_process";
39498
+ import { execFile as execFile5, spawn as spawn4 } from "node:child_process";
39146
39499
  import { randomUUID as randomUUID2 } from "node:crypto";
39147
39500
  import {
39148
39501
  appendFileSync,
39149
39502
  closeSync as closeSync2,
39150
39503
  constants,
39151
39504
  copyFileSync,
39152
- existsSync as existsSync15,
39505
+ existsSync as existsSync17,
39153
39506
  fstatSync,
39154
39507
  mkdirSync as mkdirSync12,
39155
39508
  openSync as openSync3,
39156
39509
  readdirSync as readdirSync9,
39157
- readFileSync as readFileSync19,
39510
+ readFileSync as readFileSync20,
39158
39511
  readSync as readSync2,
39159
39512
  rmdirSync as rmdirSync2,
39160
39513
  statSync as statSync8
39161
39514
  } from "node:fs";
39162
- import { basename as basename5, dirname as dirname15, extname as extname2, isAbsolute as isAbsolute3, join as join22, normalize as normalize2, relative as relative3 } from "node:path";
39515
+ import { basename as basename5, dirname as dirname16, extname as extname2, isAbsolute as isAbsolute3, join as join23, normalize as normalize2, relative as relative3 } from "node:path";
39163
39516
  import { setTimeout as delay3 } from "node:timers/promises";
39164
39517
  import { fileURLToPath as fileURLToPath5 } from "node:url";
39165
- import { promisify as promisify6 } from "node:util";
39518
+ import { promisify as promisify5 } from "node:util";
39519
+ function editorRestartTimeoutMs() {
39520
+ const override = Number.parseInt(envValue("GDHARNESS_EDITOR_RESTART_TIMEOUT_MS") ?? "", 10);
39521
+ return Number.isInteger(override) && override > 0 ? override : 90000;
39522
+ }
39523
+ function restartUnderway(note, now = Date.now()) {
39524
+ return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
39525
+ }
39166
39526
  function captureDestinationRefusal(outputPath, projects) {
39167
39527
  if (/^(res|user):\/\//.test(outputPath)) {
39168
39528
  return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
@@ -39173,16 +39533,16 @@ function captureDestinationRefusal(outputPath, projects) {
39173
39533
  if (extname2(outputPath).toLowerCase() !== ".png") {
39174
39534
  return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
39175
39535
  }
39176
- const directory = realPathOr(dirname15(outputPath));
39177
- if (!existsSync15(directory)) {
39178
- return `outputPath's directory ${dirname15(outputPath)} does not exist; a capture creates no directories.`;
39536
+ const directory = realPathOr(dirname16(outputPath));
39537
+ if (!existsSync17(directory)) {
39538
+ return `outputPath's directory ${dirname16(outputPath)} does not exist; a capture creates no directories.`;
39179
39539
  }
39180
- const where = join22(directory, basename5(outputPath));
39540
+ const where = join23(directory, basename5(outputPath));
39181
39541
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
39182
39542
  if (inside !== undefined) {
39183
39543
  return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
39184
39544
  }
39185
- if (existsSync15(where)) {
39545
+ if (existsSync17(where)) {
39186
39546
  return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
39187
39547
  }
39188
39548
  return null;
@@ -39213,16 +39573,18 @@ function describeHalt(halt) {
39213
39573
  function exited(run) {
39214
39574
  return run.exitCode !== null || run.exitSignal !== null;
39215
39575
  }
39216
- function endingOf(failed) {
39217
- return {
39218
- exitCode: typeof failed.code === "number" ? failed.code : null,
39219
- exitSignal: typeof failed.signal === "string" ? failed.signal : null,
39220
- failure: typeof failed.code === "string" ? failed.code : null
39221
- };
39222
- }
39223
39576
  function howItExited(run) {
39224
39577
  return run.exitSignal === null ? `exit code ${run.exitCode ?? "unknown"}` : `ended by ${run.exitSignal}, with no exit code`;
39225
39578
  }
39579
+ function errorsUnread(run) {
39580
+ return run.throughEditor && typeof run.errorReport !== "string";
39581
+ }
39582
+ function cleanVerdict(run) {
39583
+ if (run.consoleLost === true || errorsUnread(run)) {
39584
+ return;
39585
+ }
39586
+ return run.log.count("error") === 0;
39587
+ }
39226
39588
  function stillRunning(run) {
39227
39589
  if (run === null || exited(run) || run.endedUnwatched === true) {
39228
39590
  return false;
@@ -39238,6 +39600,11 @@ function stillRunning(run) {
39238
39600
  }
39239
39601
  return true;
39240
39602
  }
39603
+ function playOf(answer) {
39604
+ const play = readNumber(answer, "play");
39605
+ const editorPid = readNumber(answer, "editorPid");
39606
+ return play === undefined || editorPid === undefined ? null : { play, editorPid };
39607
+ }
39241
39608
  function runIsUp(run, editorSays, now = Date.now()) {
39242
39609
  if (run?.throughEditor !== true) {
39243
39610
  return stillRunning(run);
@@ -39285,11 +39652,19 @@ function stopVerdict(stop) {
39285
39652
  return {
39286
39653
  stopped: true,
39287
39654
  endedPid: stop.endedPid,
39288
- clean: stop.errors === 0,
39655
+ clean: stop.clean,
39289
39656
  note: "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running.",
39290
39657
  withChildren: false
39291
39658
  };
39292
39659
  }
39660
+ if (stop.ending === "unreached") {
39661
+ return {
39662
+ stopped: false,
39663
+ notSignalled: stop.endedPid,
39664
+ note: "Nothing was ended: the editor could not be asked to stop the scene it is playing, so the game may still be running. editor_status says whether the editor is connected and playing.",
39665
+ withChildren: false
39666
+ };
39667
+ }
39293
39668
  if (stop.ending === "refused") {
39294
39669
  return {
39295
39670
  stopped: false,
@@ -39298,22 +39673,31 @@ function stopVerdict(stop) {
39298
39673
  withChildren: false
39299
39674
  };
39300
39675
  }
39676
+ if (stop.ending === "lingering") {
39677
+ const told = stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing and was still playing it" : `The editor was asked to stop the scene it is playing, and its game, pid ${stop.endedPid}, was still running` : `pid ${stop.endedPid ?? "unknown"} was told to end and was still running`;
39678
+ return {
39679
+ stopped: false,
39680
+ stillGoing: stop.endedPid,
39681
+ note: `${told} ${STOP_WAIT_MS / 1000} seconds later, so it has not stopped. editor_status says when it has gone, and editor_run stop asks again.`,
39682
+ withChildren: true
39683
+ };
39684
+ }
39301
39685
  return {
39302
39686
  stopped: true,
39303
39687
  endedPid: stop.endedPid,
39304
- clean: stop.errors === 0,
39305
- note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}${stop.ending === "gone" ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`}`,
39688
+ clean: stop.clean,
39689
+ note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}`,
39306
39690
  withChildren: true
39307
39691
  };
39308
39692
  }
39309
39693
  function endedPreviousRun(ended) {
39310
- if (ended === null || ended.ending === "refused") {
39694
+ if (ended?.ending !== "gone") {
39311
39695
  return;
39312
39696
  }
39313
39697
  return ended.pid ?? true;
39314
39698
  }
39315
39699
  function previousRunLeft(ended) {
39316
- return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
39700
+ return (ended?.ending === "refused" || ended?.ending === "lingering") && ended.pid !== null ? ended.pid : undefined;
39317
39701
  }
39318
39702
  function engineNamesOf(executable) {
39319
39703
  return CONSOLE_WRAPPER.test(executable) ? [executable, executable.replace(CONSOLE_WRAPPER, "$1")] : [executable];
@@ -39389,8 +39773,13 @@ function endedToStartThis(ended) {
39389
39773
  if (ended.ending === "refused") {
39390
39774
  return ` ${which} was not ended: that pid no longer answers as the process the run was started as, so nothing was signalled. If it is still the game, it is running beside this one and you have to end it yourself; if it is not, it belongs to something else.`;
39391
39775
  }
39392
- const after = ended.ending === "lingering" ? ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be running beside this one: editor_status says whether it is.` : "";
39393
- return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.${after}`;
39776
+ if (ended.ending === "unreached") {
39777
+ return ` ${which} was not ended here: the editor could not be asked to stop it. The editor stops a play before starting the next, so it is not running beside this one if this start reached the editor.`;
39778
+ }
39779
+ if (ended.ending === "lingering") {
39780
+ return ` ${which} was told to end to start this one and was still running ${STOP_WAIT_MS / 1000} seconds later, so it is running beside this one: editor_status says when it has gone.`;
39781
+ }
39782
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
39394
39783
  }
39395
39784
  function runtimeVerdict(endpoint, after) {
39396
39785
  if (endpoint !== null) {
@@ -39514,19 +39903,58 @@ function withScanWait(outcome, scanned) {
39514
39903
  if (scanned.waitedMs === 0) {
39515
39904
  return outcome;
39516
39905
  }
39517
- const said = scanWaitAnswer(scanned);
39906
+ const said = scanWaitAnswer(scanned, "engine");
39518
39907
  if (outcome.ok) {
39519
39908
  return { ...outcome, payload: { ...outcome.payload, ...said } };
39520
39909
  }
39521
39910
  return said.scanNote === undefined ? outcome : { ...outcome, message: `${outcome.message} ${said.scanNote}` };
39522
39911
  }
39523
- function scanWaitAnswer(scanned) {
39912
+ function notRunNote(notRun, failFast) {
39913
+ if (notRun <= 0) {
39914
+ return;
39915
+ }
39916
+ const cases = `${notRun} case${notRun === 1 ? "" : "s"}`;
39917
+ return failFast ? `This run stopped at the first failure in each suite it failed in, so ${cases} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : `${cases} counted in ${notRun === 1 ? "its suite" : "their suites"} never ran and count as neither passed nor failed. failFast was not set, so it was not that: the suites below with notRun are the ones to read.`;
39918
+ }
39919
+ function exportAnswer(ending, asked, before, after) {
39920
+ const { log } = ending;
39921
+ const written = after !== null && after !== before;
39922
+ const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
39923
+ const verdict = {
39924
+ exported: ending.exitCode === 0 && ending.failure === null && log.count("error") === 0 && written,
39925
+ ...asked,
39926
+ exitCode: ending.exitCode,
39927
+ exitSignal: ending.exitSignal ?? undefined,
39928
+ failure: ending.failure ?? undefined,
39929
+ errors: log.count("error"),
39930
+ warnings: log.count("warning"),
39931
+ entries: forAnswer(problems.entries),
39932
+ ...problems.omitted === 0 ? {} : { entriesOmitted: problems.omitted }
39933
+ };
39934
+ if (verdict.exported) {
39935
+ return { content: [{ type: "text", text: JSON.stringify(verdict, null, 2) }] };
39936
+ }
39937
+ const stale = !written && after !== null ? ` The ${asked.outputPath} there is from before this export, which did not write it.` : "";
39938
+ const why = ending.exitSignal === null ? stale : ` The engine was ${howItEnded(ending)}, so it has no exit code.${stale}`;
39939
+ return {
39940
+ content: [
39941
+ {
39942
+ type: "text",
39943
+ text: `Export with preset '${asked.preset}' did not produce ${asked.outputPath}.${why}`
39944
+ },
39945
+ { type: "text", text: JSON.stringify(verdict, null, 2) }
39946
+ ],
39947
+ isError: true
39948
+ };
39949
+ }
39950
+ function scanWaitAnswer(scanned, started = "game") {
39524
39951
  if (scanned.waitedMs === 0) {
39525
39952
  return {};
39526
39953
  }
39954
+ const still = `The editor was still scanning the project after ${SCAN_WAIT_MS / 1000} seconds, so`;
39527
39955
  return {
39528
39956
  waitedForEditorScanMs: scanned.waitedMs,
39529
- scanNote: scanned.stillScanning ? `The editor was still scanning the project after ${SCAN_WAIT_MS / 1000} seconds, so this ` + "game started during the scan and may have read a class cache being rewritten. If " + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : undefined
39957
+ scanNote: !scanned.stillScanning ? undefined : started === "game" ? `${still} this game started during the scan and may have read a class cache being rewritten. If ` + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : `${still} the engine answering this started during the scan and may have read a class cache being ` + "rewritten, and a class cache it wrote can be written over when the scan finishes. If " + 'engine_messages shows "Could not find type" for a class that is declared, editor_rescan waits ' + "for the scan to finish, and the same call after it reads the finished cache."
39530
39958
  };
39531
39959
  }
39532
39960
  function changedSettings(before, after) {
@@ -39581,16 +40009,17 @@ function importStatuses(payload) {
39581
40009
  };
39582
40010
  });
39583
40011
  }
39584
- function escapingPropertyValue(properties) {
39585
- if (typeof properties !== "object" || properties === null) {
40012
+ function escapingPropertyValue(value) {
40013
+ if (typeof value === "string") {
40014
+ return /^(res|uid):\/\//.test(value) && value.split(/[/\\]/).includes("..") ? JSON.stringify(value) : undefined;
40015
+ }
40016
+ if (typeof value !== "object" || value === null) {
39586
40017
  return;
39587
40018
  }
39588
- for (const value of Object.values(properties)) {
39589
- if (typeof value !== "string" || !value.includes("/")) {
39590
- continue;
39591
- }
39592
- if (value.split(/[/\\]/).includes("..")) {
39593
- return JSON.stringify(value);
40019
+ for (const item of Object.values(value)) {
40020
+ const found = escapingPropertyValue(item);
40021
+ if (found !== undefined) {
40022
+ return found;
39594
40023
  }
39595
40024
  }
39596
40025
  return;
@@ -39611,7 +40040,7 @@ function aboveTheRunner(entry) {
39611
40040
  }
39612
40041
  function elsewhereIn(projectPath, asked) {
39613
40042
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
39614
- if (wanted === "" || existsSync15(join22(projectPath, wanted))) {
40043
+ if (wanted === "" || existsSync17(join23(projectPath, wanted))) {
39615
40044
  return "";
39616
40045
  }
39617
40046
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -39625,7 +40054,7 @@ function elsewhereIn(projectPath, asked) {
39625
40054
  return near.length === 0 ? "" : ` The project does have ${there}.`;
39626
40055
  }
39627
40056
  function hasMainScene(projectFile) {
39628
- const scene = parseProjectGodot(readFileSync19(projectFile, "utf8"))["application"]?.["run/main_scene"];
40057
+ const scene = parseProjectGodot(readFileSync20(projectFile, "utf8"))["application"]?.["run/main_scene"];
39629
40058
  return typeof scene === "string" && scene !== "";
39630
40059
  }
39631
40060
  function runtimeAddonAnnouncesEditors(projectPath) {
@@ -39633,13 +40062,13 @@ function runtimeAddonAnnouncesEditors(projectPath) {
39633
40062
  return false;
39634
40063
  }
39635
40064
  try {
39636
- return readFileSync19(join22(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
40065
+ return readFileSync20(join23(projectPath, RUNTIME_AUTOLOAD.path), "utf8").includes('"editor_pid"');
39637
40066
  } catch {
39638
40067
  return false;
39639
40068
  }
39640
40069
  }
39641
40070
  function editorPlaysHeadless(projectFile) {
39642
- const runArgs = parseProjectGodot(readFileSync19(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
40071
+ const runArgs = parseProjectGodot(readFileSync20(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
39643
40072
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
39644
40073
  }
39645
40074
  function camelCased(params) {
@@ -39658,7 +40087,7 @@ function needsItsOwnProcess(asked) {
39658
40087
  class GodotServer {
39659
40088
  mcp;
39660
40089
  locator = new GodotLocator;
39661
- operationsScript = join22(__dirname2, "godot", "operations", "godot_operations.gd");
40090
+ operationsScript = join23(__dirname2, "godot", "operations", "godot_operations.gd");
39662
40091
  godotBridge;
39663
40092
  tools = buildToolDefinitions();
39664
40093
  activeProcess = null;
@@ -39680,6 +40109,7 @@ class GodotServer {
39680
40109
  shutdownInitiated = false;
39681
40110
  launchedEditor = null;
39682
40111
  launchedFrom = null;
40112
+ launchedDropped = null;
39683
40113
  ownProject;
39684
40114
  announcedAt = null;
39685
40115
  handingOverFrom = null;
@@ -39687,9 +40117,12 @@ class GodotServer {
39687
40117
  constructor() {
39688
40118
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
39689
40119
  this.godotBridge = getDefaultBridge(this.ownProject);
40120
+ this.godotBridge.on("godot_connected", () => {
40121
+ this.settleTheLaunchReading();
40122
+ });
39690
40123
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
39691
40124
  this.setupToolHandlers();
39692
- setupResourceHandlers(this.mcp, () => this.lastProjectPath);
40125
+ setupResourceHandlers(this.mcp, () => this.ourProject() ?? this.lastProjectPath);
39693
40126
  this.mcp.server.onerror = (error) => {
39694
40127
  console.error("[MCP Error]", error);
39695
40128
  };
@@ -39955,8 +40388,9 @@ class GodotServer {
39955
40388
  if (!checked.ok) {
39956
40389
  return checked.response;
39957
40390
  }
39958
- if (typeof args["projectPath"] === "string") {
39959
- this.lastProjectPath = args["projectPath"];
40391
+ const named = args["projectPath"];
40392
+ if (typeof named === "string" && existsSync17(join23(named, "project.godot"))) {
40393
+ this.lastProjectPath = named;
39960
40394
  }
39961
40395
  this.updates.refresh();
39962
40396
  const answer = await withCallSignal(extra.signal, () => this.answered(spec.name, checked.op ?? "", args));
@@ -39996,6 +40430,17 @@ class GodotServer {
39996
40430
  return answer;
39997
40431
  }
39998
40432
  const moved = this.projectHasMovedOn();
40433
+ if (moved !== null && !isNewer(moved, SERVER_VERSION)) {
40434
+ this.noticedUpdate = true;
40435
+ this.callsSinceNotice = 0;
40436
+ return this.saying(answer, {
40437
+ project_addons_behind_this_server: {
40438
+ server_is: SERVER_VERSION,
40439
+ project_is: moved,
40440
+ what_to_do: `The project's gdharness addons are ${moved}, older than this ${SERVER_VERSION} server, so an ` + "editor restart loads the old code and reconnecting spawns this same server again. Tell the " + "user, and offer to run gdharness upgrade in the project, which replaces the addons with " + "this server's; ask first, since it changes files in their project. editor_launch restart " + "then loads them."
40441
+ }
40442
+ });
40443
+ }
39999
40444
  if (moved !== null) {
40000
40445
  this.noticedUpdate = true;
40001
40446
  this.callsSinceNotice = 0;
@@ -40028,7 +40473,7 @@ class GodotServer {
40028
40473
  return this.saying(answer, {
40029
40474
  update_available: {
40030
40475
  ...notice,
40031
- what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it restarts their editor and the " + "MCP server has to be reconnected afterwards."
40476
+ what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it replaces the addons in their project, " + "and afterwards the MCP server has to be reconnected and the editor restarted with " + "editor_launch restart."
40032
40477
  }
40033
40478
  });
40034
40479
  }
@@ -40045,6 +40490,11 @@ class GodotServer {
40045
40490
  const installed = installedAddonVersion(this.ownProject);
40046
40491
  return installed === null || installed === SERVER_VERSION ? null : installed;
40047
40492
  }
40493
+ editorCodeOnDisk() {
40494
+ const status = this.godotBridge.getStatus();
40495
+ const project = (status.connected ? status.projectPath : undefined) ?? this.ownProject;
40496
+ return project === null || project === "" ? undefined : installedEditorDigest(project);
40497
+ }
40048
40498
  configDisagreement() {
40049
40499
  return this.ownProject === null ? null : configDisagrees(this.ownProject, SERVER_VERSION);
40050
40500
  }
@@ -40158,6 +40608,9 @@ class GodotServer {
40158
40608
  const contained = this.containProjectFiles(asks);
40159
40609
  return contained.ok ? await this.handleViaBridge(editorSide, contained.value) : contained.response;
40160
40610
  }
40611
+ if (headless === "create_script") {
40612
+ return await this.handleCreateScript(answerable);
40613
+ }
40161
40614
  if (headless !== "refresh_class_cache") {
40162
40615
  return await this.headless(headless, answerable);
40163
40616
  }
@@ -40328,7 +40781,7 @@ class GodotServer {
40328
40781
  return await this.handleRuntimeCommand("click", {
40329
40782
  ...whichGame(args),
40330
40783
  path: readNonEmptyString(args, "nodePath") ?? "",
40331
- button: readString(args, "button") ?? "left",
40784
+ button: args["button"] ?? "left",
40332
40785
  double: readBoolean(args, "doubleClick") ?? false,
40333
40786
  ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
40334
40787
  ...args["index"] === undefined ? {} : { index: args["index"] }
@@ -40400,8 +40853,8 @@ class GodotServer {
40400
40853
  if (path === undefined) {
40401
40854
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
40402
40855
  }
40403
- const file = join22(path, "project.godot");
40404
- if (!existsSync15(file)) {
40856
+ const file = join23(path, "project.godot");
40857
+ if (!existsSync17(file)) {
40405
40858
  return {
40406
40859
  ok: false,
40407
40860
  response: this.createErrorResponse(`Not a Godot project: ${path}`, [
@@ -40611,13 +41064,30 @@ class GodotServer {
40611
41064
  }
40612
41065
  contained[key] = `res://${location.relativePath}`;
40613
41066
  }
40614
- const escaping = escapingPropertyValue(args["properties"]);
41067
+ const escaping = escapingPropertyValue([args["properties"], args["track"]]);
40615
41068
  if (escaping !== undefined) {
40616
41069
  return {
40617
41070
  ok: false,
40618
41071
  response: this.createErrorResponse(`The property value ${escaping} resolves outside the project directory.`, PATH_SOLUTIONS)
40619
41072
  };
40620
41073
  }
41074
+ const sources = args["sources"];
41075
+ if (Array.isArray(sources)) {
41076
+ const resolved = [];
41077
+ for (const source of sources) {
41078
+ const texture = typeof source === "object" && source !== null ? source["texture"] : undefined;
41079
+ if (typeof texture !== "string") {
41080
+ resolved.push(source);
41081
+ continue;
41082
+ }
41083
+ const location = resolveWithinProject(projectPath, texture);
41084
+ if (!location.ok) {
41085
+ return { ok: false, response: this.createErrorResponse(location.reason, PATH_SOLUTIONS) };
41086
+ }
41087
+ resolved.push({ ...source, texture: `res://${location.relativePath}` });
41088
+ }
41089
+ contained["sources"] = resolved;
41090
+ }
40621
41091
  const pluginName = readNonEmptyString(args, "pluginName");
40622
41092
  if (pluginName !== undefined) {
40623
41093
  const plugin = resolveWithinProject(projectPath, `addons/${pluginName}/plugin.cfg`);
@@ -40665,6 +41135,7 @@ class GodotServer {
40665
41135
  return this.answer(withScanWait({
40666
41136
  ok: true,
40667
41137
  messages: imported.messages,
41138
+ ...imported.messagesOmitted === undefined ? {} : { messagesOmitted: imported.messagesOmitted },
40668
41139
  payload: {
40669
41140
  uidsCreated: given,
40670
41141
  stillWithoutUid: after,
@@ -40715,16 +41186,25 @@ class GodotServer {
40715
41186
  if (changed.length === 0) {
40716
41187
  return written;
40717
41188
  }
40718
- const taken = await this.editorTakes("adopt_project_settings", { settings: changed });
41189
+ const adopting = {};
41190
+ const taken = await this.editorTakes("adopt_project_settings", { settings: changed }, adopting);
41191
+ if (taken["editorNote"] !== undefined) {
41192
+ return this.jsonTextResponse({ ...answer, ...taken });
41193
+ }
41194
+ const adopted = Array.isArray(adopting["adopted"]) ? adopting["adopted"].map(String) : [];
41195
+ const passedOver = changed.filter((name) => !adopted.includes(name));
40719
41196
  return this.jsonTextResponse({
40720
41197
  ...answer,
40721
- ...taken,
40722
- ...taken["editorNote"] === undefined ? { editorAdopted: changed } : {}
41198
+ editorAdopted: adopted,
41199
+ ...passedOver.length > 0 ? { editorPassedOver: passedOver } : {}
40723
41200
  });
40724
41201
  }
40725
- async editorTakes(command, args) {
41202
+ async editorTakes(command, args, answered = {}) {
40726
41203
  try {
40727
- await this.godotBridge.invokeTool(command, args);
41204
+ const result = await this.godotBridge.invokeTool(command, args);
41205
+ if (typeof result === "object" && result !== null) {
41206
+ Object.assign(answered, result);
41207
+ }
40728
41208
  return {};
40729
41209
  } catch (error) {
40730
41210
  const message = errorMessage(error);
@@ -40762,7 +41242,19 @@ class GodotServer {
40762
41242
  }
40763
41243
  const applied = await this.reimport(project.value.path, readNonEmptyString(contained.value, "resourcePath"), true, readPositiveNumber(args, "timeoutMs") ?? REIMPORT_TIMEOUT_MS);
40764
41244
  if (!applied.ok) {
40765
- return applied.response;
41245
+ const listed = written.payload["updated_options"];
41246
+ const updated = Array.isArray(listed) ? listed.map(String) : [];
41247
+ const which = updated.length === 0 ? "The options were" : `${updated.join(", ")} ${updated.length === 1 ? "was" : "were"}`;
41248
+ return {
41249
+ ...applied.response,
41250
+ content: [
41251
+ {
41252
+ type: "text",
41253
+ text: `${which} written to the import file, and the reimport that applies them failed, so the resource is still imported the old way until the editor's next scan or project_import reimport imports it. Why the reimport failed:`
41254
+ },
41255
+ ...applied.response.content
41256
+ ]
41257
+ };
40766
41258
  }
40767
41259
  return this.jsonTextResponse({ ...written.payload, reimport: applied.payload });
40768
41260
  }
@@ -40780,7 +41272,14 @@ class GodotServer {
40780
41272
  response: this.createErrorResponse(`${missingSource[0]} is not on disk, so there is nothing to import.`)
40781
41273
  };
40782
41274
  }
40783
- const targets = listed.filter((one) => one.status !== "missing_source" && (force || one.status !== "up_to_date")).map((one) => one.path);
41275
+ const never = listed.find((one) => one.status === "not_imported");
41276
+ if (resourcePath !== undefined && never !== undefined) {
41277
+ return {
41278
+ ok: false,
41279
+ response: this.createErrorResponse(`${never.path} is not something the engine imports: ${never.reason ?? "it has no importer"}.`)
41280
+ };
41281
+ }
41282
+ const targets = listed.filter((one) => one.status !== "missing_source" && one.status !== "not_imported" && (force || one.status !== "up_to_date")).map((one) => one.path);
40784
41283
  if (targets.length === 0) {
40785
41284
  return {
40786
41285
  ok: true,
@@ -40819,9 +41318,7 @@ class GodotServer {
40819
41318
  if (!imported.ok) {
40820
41319
  return { ok: false, response: this.answer(withScanWait(imported, scanned)) };
40821
41320
  }
40822
- if (imported.messages.length > 0) {
40823
- extra["engine_messages"] = imported.messages;
40824
- }
41321
+ Object.assign(extra, engineExtras(imported));
40825
41322
  if (imported.extensionNote !== undefined) {
40826
41323
  extra["extensionNote"] = imported.extensionNote;
40827
41324
  }
@@ -40904,17 +41401,15 @@ class GodotServer {
40904
41401
  return withScanWait(outcome, scanned);
40905
41402
  }
40906
41403
  answer(outcome) {
41404
+ const extras = engineExtras(outcome);
40907
41405
  if (!outcome.ok) {
40908
41406
  const response = this.createErrorResponse(outcome.message);
40909
- if (outcome.messages.length > 0) {
40910
- response.content.push({
40911
- type: "text",
40912
- text: JSON.stringify({ engine_messages: outcome.messages }, null, 2)
40913
- });
41407
+ if (Object.keys(extras).length > 0) {
41408
+ response.content.push({ type: "text", text: JSON.stringify(extras, null, 2) });
40914
41409
  }
40915
41410
  return response;
40916
41411
  }
40917
- return this.jsonTextResponse(outcome.messages.length > 0 ? { ...outcome.payload, engine_messages: outcome.messages } : outcome.payload);
41412
+ return this.jsonTextResponse({ ...outcome.payload, ...extras });
40918
41413
  }
40919
41414
  async handleProjectInfo(args) {
40920
41415
  const include = readStringArray(args, "include") ?? [];
@@ -40930,7 +41425,7 @@ class GodotServer {
40930
41425
  if (!engine.ok) {
40931
41426
  return engine.response;
40932
41427
  }
40933
- const application = parseProjectGodot(readFileSync19(project.value.file, "utf8"))["application"] ?? {};
41428
+ const application = parseProjectGodot(readFileSync20(project.value.file, "utf8"))["application"] ?? {};
40934
41429
  const name = application["config/name"];
40935
41430
  const mainScene = application["run/main_scene"];
40936
41431
  const info = {
@@ -40947,10 +41442,14 @@ class GodotServer {
40947
41442
  continue;
40948
41443
  }
40949
41444
  const outcome = await this.operation(spec.operation, spec.params(args, detailed), project.value.path);
40950
- info[section] = outcome.ok ? outcome.messages.length > 0 ? { ...outcome.payload, engine_messages: outcome.messages } : outcome.payload : { error: outcome.message, engine_messages: outcome.messages };
41445
+ info[section] = outcome.ok ? { ...outcome.payload, ...engineExtras(outcome) } : { error: outcome.message, ...engineExtras(outcome) };
40951
41446
  }
40952
41447
  return this.jsonTextResponse(info);
40953
41448
  }
41449
+ fileTypes(args) {
41450
+ const named = readStringArray(args, "fileTypes");
41451
+ return named === undefined ? {} : { fileTypes: named };
41452
+ }
40954
41453
  handleSearchProject(args) {
40955
41454
  const project = this.project(args);
40956
41455
  if (!project.ok) {
@@ -40959,16 +41458,7 @@ class GodotServer {
40959
41458
  try {
40960
41459
  return this.jsonTextResponse(searchProject(project.value.path, {
40961
41460
  query: readString(args, "query") ?? "",
40962
- fileTypes: readStringArray(args, "fileTypes") ?? [
40963
- "gd",
40964
- "tscn",
40965
- "tres",
40966
- "gdshader",
40967
- "cfg",
40968
- "md",
40969
- "txt",
40970
- "json"
40971
- ],
41461
+ ...this.fileTypes(args),
40972
41462
  regex: readBoolean(args, "regex") ?? false,
40973
41463
  caseSensitive: readBoolean(args, "caseSensitive") ?? false,
40974
41464
  maxResults: readPositiveNumber(args, "maxResults") ?? 100
@@ -40991,18 +41481,18 @@ class GodotServer {
40991
41481
  if (!engine.ok) {
40992
41482
  return engine.response;
40993
41483
  }
40994
- const folder = dirname15(output.absolutePath);
41484
+ const folder = dirname16(output.absolutePath);
40995
41485
  try {
40996
41486
  mkdirSync12(folder, { recursive: true });
40997
41487
  } catch (error) {
40998
- return this.createErrorResponse(`Cannot create ${dirname15(output.relativePath)} to export into: ${errorMessage(error)}`);
41488
+ return this.createErrorResponse(`Cannot create ${dirname16(output.relativePath)} to export into: ${errorMessage(error)}`);
40999
41489
  }
41000
41490
  const debug = readBoolean(args, "debug") ?? false;
41001
41491
  const exportLogs = scratchDirectory("export");
41002
41492
  const exportArgs = [
41003
41493
  "--headless",
41004
41494
  "--log-file",
41005
- join22(exportLogs, "engine.log"),
41495
+ join23(exportLogs, "engine.log"),
41006
41496
  "--path",
41007
41497
  project.value.path,
41008
41498
  debug ? "--export-debug" : "--export-release",
@@ -41010,55 +41500,18 @@ class GodotServer {
41010
41500
  output.absolutePath
41011
41501
  ];
41012
41502
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
41013
- const log = new GameLog;
41014
- let ending = { exitCode: 0, exitSignal: null, failure: null };
41503
+ const writtenAt = () => existsSync17(output.absolutePath) ? statSync8(output.absolutePath).mtimeMs : null;
41504
+ const before = writtenAt();
41505
+ let ending;
41015
41506
  try {
41016
- const { stdout, stderr } = await run5(engine.value, exportArgs, {
41017
- timeout: 300000,
41018
- maxBuffer: 64 * 1024 * 1024,
41019
- signal: callSignal()
41020
- });
41021
- log.append("stdout", stdout);
41022
- log.append("stderr", stderr);
41023
- } catch (error) {
41024
- if (!(error instanceof Error && ("stdout" in error) && ("stderr" in error))) {
41025
- return this.createErrorResponse(`Export could not be run: ${errorMessage(error)}`);
41026
- }
41027
- const failed = error;
41028
- log.append("stdout", failed.stdout);
41029
- log.append("stderr", failed.stderr);
41030
- ending = endingOf(failed);
41507
+ ending = await runEngine(engine.value, exportArgs, { timeout: { ms: 300000, said: "five minutes" } });
41031
41508
  } finally {
41032
41509
  discard(exportLogs);
41033
41510
  }
41034
- log.finish();
41035
- const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
41036
- const verdict = {
41037
- exported: ending.exitCode === 0 && log.count("error") === 0 && existsSync15(output.absolutePath),
41038
- preset,
41039
- outputPath: output.relativePath,
41040
- debug,
41041
- exitCode: ending.exitCode,
41042
- exitSignal: ending.exitSignal ?? undefined,
41043
- failure: ending.failure ?? undefined,
41044
- errors: log.count("error"),
41045
- warnings: log.count("warning"),
41046
- entries: forAnswer(problems.entries)
41047
- };
41048
- if (!verdict.exported) {
41049
- const why = ending.exitSignal === null ? ending.failure === null ? "" : ` The engine was ended by Node (${ending.failure}), so it has no exit code.` : ` The engine was ended by ${ending.exitSignal}, so it has no exit code: the export ran past its five minutes or the call was cancelled.`;
41050
- return {
41051
- content: [
41052
- {
41053
- type: "text",
41054
- text: `Export with preset '${preset}' did not produce ${output.relativePath}.${why}`
41055
- },
41056
- { type: "text", text: JSON.stringify(verdict, null, 2) }
41057
- ],
41058
- isError: true
41059
- };
41511
+ if (ending.exitSignal === null && ending.exitCode === null) {
41512
+ return this.createErrorResponse(`Export could not be run: ${ending.failure ?? "the engine gave no exit status"}`);
41060
41513
  }
41061
- return this.jsonTextResponse(verdict);
41514
+ return exportAnswer(ending, { preset, outputPath: output.relativePath, debug }, before, writtenAt());
41062
41515
  }
41063
41516
  async handleRunTests(args) {
41064
41517
  const project = this.project(args);
@@ -41070,7 +41523,7 @@ class GodotServer {
41070
41523
  return contained.response;
41071
41524
  }
41072
41525
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
41073
- if (!existsSync15(join22(project.value.path, runner))) {
41526
+ if (!existsSync17(join23(project.value.path, runner))) {
41074
41527
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
41075
41528
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
41076
41529
  ]);
@@ -41136,21 +41589,22 @@ class GodotServer {
41136
41589
  if (timedOut && run.process.pid !== undefined) {
41137
41590
  await untilGone([run.process.pid]);
41138
41591
  }
41139
- const reportsDir = join22(project.value.path, ".godot", ...ours.split("/"));
41592
+ await run.settled;
41593
+ const reportsDir = join23(project.value.path, ".godot", ...ours.split("/"));
41140
41594
  let report = null;
41141
41595
  let reportProblem = null;
41142
41596
  try {
41143
- const written = existsSync15(reportsDir) ? readdirSync9(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
41597
+ const written = existsSync17(reportsDir) ? readdirSync9(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
41144
41598
  const newest = written.at(-1);
41145
41599
  if (newest !== undefined) {
41146
- report = parseJUnit(readFileSync19(join22(reportsDir, newest, "results.xml"), "utf8"));
41600
+ report = parseJUnit(readFileSync20(join23(reportsDir, newest, "results.xml"), "utf8"));
41147
41601
  }
41148
41602
  } catch (error) {
41149
41603
  reportProblem = errorMessage(error);
41150
41604
  } finally {
41151
41605
  discard(reportsDir, userData);
41152
41606
  try {
41153
- rmdirSync2(dirname15(reportsDir));
41607
+ rmdirSync2(dirname16(reportsDir));
41154
41608
  } catch {}
41155
41609
  }
41156
41610
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries.map(aboveTheRunner);
@@ -41163,7 +41617,11 @@ class GodotServer {
41163
41617
  105: "script errors"
41164
41618
  };
41165
41619
  const exitCode = run.exitCode;
41166
- const printed = run.log.select({ severity: "info", sinceLastCall: false, limit: 200 }).entries;
41620
+ const printed = run.log.select({
41621
+ severity: "info",
41622
+ sinceLastCall: false,
41623
+ limit: Number.POSITIVE_INFINITY
41624
+ }).entries;
41167
41625
  const said = printed.map((entry) => entry.text);
41168
41626
  const nothingRan = timedOut ? null : whyNoReport(said, asked);
41169
41627
  const cutShort = timedOut ? timedOutVerdict(timeoutMs, silentForMs) : null;
@@ -41179,7 +41637,7 @@ class GodotServer {
41179
41637
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
41180
41638
  };
41181
41639
  if (report === null) {
41182
- const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).`;
41640
+ const note = nothingRan !== null ? `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
41183
41641
  return {
41184
41642
  content: [
41185
41643
  { type: "text", text: note },
@@ -41216,10 +41674,21 @@ class GodotServer {
41216
41674
  orphans: suite.orphans,
41217
41675
  message: `${suite.orphans} node${suite.orphans === 1 ? "" : "s"} were still in the tree when this suite finished. A node built in a test and neither freed nor added to the tree is the usual cause; free it, or add it with auto_free().`
41218
41676
  }));
41219
- const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
41677
+ const unclean = report.suites.filter((suite) => suite.failures > 0 || suite.errors > 0 || suite.hookFailures > 0 || suite.hookErrors > 0 || suite.skipped > 0 || orphansIn(suite.path) > 0);
41678
+ const hooksPrinted = hookFailuresPrinted(said);
41679
+ const hookFailures = report.suites.filter((suite) => suite.hookFailures + suite.hookErrors > 0).flatMap((suite) => {
41680
+ const printedHere = hooksPrinted.filter((hook) => hook.path === suite.path);
41681
+ return printedHere.length > 0 ? printedHere.map((hook) => ({ suite: suite.name, ...hook })) : [
41682
+ {
41683
+ suite: suite.name,
41684
+ path: suite.path,
41685
+ message: "gdUnit4 counted a failure in this suite that none of its cases carry, which is what a failed before() or after() leaves, and printed no line saying which."
41686
+ }
41687
+ ];
41688
+ });
41220
41689
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
41221
41690
  return this.jsonTextResponse({
41222
- passed: !timedOut && exitCode === 0 && report.failures === 0 && report.errors === 0,
41691
+ passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0,
41223
41692
  verdict,
41224
41693
  ...timedOut ? { timedOut, hung, silentForMs } : {},
41225
41694
  exitCode,
@@ -41230,13 +41699,14 @@ class GodotServer {
41230
41699
  skipped: report.skipped,
41231
41700
  time: report.time,
41232
41701
  failed,
41702
+ hookFailures: hookFailures.length > 0 ? hookFailures : undefined,
41233
41703
  warnings: warnings.length > 0 ? warnings : undefined,
41234
41704
  orphans: orphans.total > 0 ? orphans.total : undefined,
41235
41705
  notRun: notRun > 0 ? notRun : undefined,
41236
41706
  ...printedLines,
41237
41707
  savesNote,
41238
41708
  ...scanned,
41239
- note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
41709
+ note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true),
41240
41710
  suites: unclean.map((suite) => ({
41241
41711
  name: suite.name,
41242
41712
  path: suite.path,
@@ -41244,6 +41714,7 @@ class GodotServer {
41244
41714
  notRun: suite.discovered > suite.tests ? suite.discovered - suite.tests : undefined,
41245
41715
  failures: suite.failures,
41246
41716
  errors: suite.errors,
41717
+ hookFailures: suite.hookFailures + suite.hookErrors > 0 ? suite.hookFailures + suite.hookErrors : undefined,
41247
41718
  skipped: suite.skipped,
41248
41719
  orphans: orphansIn(suite.path) > 0 ? orphansIn(suite.path) : undefined,
41249
41720
  time: suite.time
@@ -41257,7 +41728,7 @@ class GodotServer {
41257
41728
  }
41258
41729
  async godotVersion(godotPath) {
41259
41730
  try {
41260
- const { stdout } = await run5(godotPath, ["--version"], { timeout: 1e4 });
41731
+ const { stdout } = await run4(godotPath, ["--version"], { timeout: 1e4 });
41261
41732
  this.lastGodotVersion = stdout.trim();
41262
41733
  return this.lastGodotVersion;
41263
41734
  } catch (error) {
@@ -41269,8 +41740,18 @@ class GodotServer {
41269
41740
  const answer = await this.handleLSP("lsp_get_diagnostics", args);
41270
41741
  const payload = asParams(JSON.parse(answer.content[0]?.text ?? "{}"));
41271
41742
  if (payload["error"] !== undefined) {
41272
- const reason = payload["error"];
41273
- return this.createErrorResponse(`Diagnostics unavailable: ${typeof reason === "string" ? reason : JSON.stringify(reason)}`, [
41743
+ const reason = typeof payload["error"] === "string" ? payload["error"] : JSON.stringify(payload["error"]);
41744
+ if (payload["refusedArguments"] === true) {
41745
+ return this.createErrorResponse(reason);
41746
+ }
41747
+ if (typeof payload["serves"] === "string") {
41748
+ return this.createErrorResponse(`Diagnostics unavailable: ${reason}`, [
41749
+ "editor_status names the editor on the bridge and the port it says it serves",
41750
+ "editor_launch opens this project's own editor, on a language server port of its own",
41751
+ "GDHARNESS_LSP_PORT points this server at the right one"
41752
+ ]);
41753
+ }
41754
+ return this.createErrorResponse(`Diagnostics unavailable: ${reason}`, [
41274
41755
  `Ensure the Godot editor is running with its language server enabled, on port ${this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT)}`,
41275
41756
  "GDHARNESS_LSP_PORT points this server at another one"
41276
41757
  ]);
@@ -41288,7 +41769,7 @@ class GodotServer {
41288
41769
  warnings: diagnostics.length - errors,
41289
41770
  diagnostics,
41290
41771
  ...disagrees
41291
- }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
41772
+ }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
41292
41773
  }
41293
41774
  whereTheFileDisagrees(diagnostics, args) {
41294
41775
  const messages = diagnostics.map((entry) => asParams(entry)["message"]).filter((message) => typeof message === "string");
@@ -41301,11 +41782,11 @@ class GodotServer {
41301
41782
  const cached = cachedClasses(projectPath);
41302
41783
  const contradicted = cached === null ? [] : contradictedDiagnostics(messages, cached, (resourcePath) => {
41303
41784
  const contained = resolveWithinProject(projectPath, resourcePath);
41304
- if (!contained.ok || !existsSync15(contained.absolutePath)) {
41785
+ if (!contained.ok || !existsSync17(contained.absolutePath)) {
41305
41786
  return null;
41306
41787
  }
41307
41788
  try {
41308
- return readFileSync19(contained.absolutePath, "utf8");
41789
+ return readFileSync20(contained.absolutePath, "utf8");
41309
41790
  } catch {
41310
41791
  return null;
41311
41792
  }
@@ -41361,11 +41842,11 @@ class GodotServer {
41361
41842
  }
41362
41843
  sourceOfClass(projectPath, resourcePath) {
41363
41844
  const contained = resolveWithinProject(projectPath, resourcePath);
41364
- if (!contained.ok || !existsSync15(contained.absolutePath)) {
41845
+ if (!contained.ok || !existsSync17(contained.absolutePath)) {
41365
41846
  return null;
41366
41847
  }
41367
41848
  try {
41368
- return readFileSync19(contained.absolutePath, "utf8");
41849
+ return readFileSync20(contained.absolutePath, "utf8");
41369
41850
  } catch {
41370
41851
  return null;
41371
41852
  }
@@ -41439,7 +41920,7 @@ class GodotServer {
41439
41920
  }
41440
41921
  breakpointsAtRisk() {
41441
41922
  const status = this.godotBridge.getStatus();
41442
- if (!status.connected || status.syncsBreakpoints === true) {
41923
+ if (!hasSaidWhoItIs(status) || status.syncsBreakpoints === true) {
41443
41924
  return {};
41444
41925
  }
41445
41926
  return {
@@ -41494,26 +41975,34 @@ class GodotServer {
41494
41975
  restartSettled(this.ownProject);
41495
41976
  return null;
41496
41977
  }
41497
- return owed;
41978
+ return { note: owed, underway: restartUnderway(owed) };
41498
41979
  }
41499
41980
  async getEditorStatusPayload() {
41500
41981
  const status = this.godotBridge.getStatus();
41501
41982
  const launchedIsUp = !status.connected && await this.launchedEditorIsUp();
41502
41983
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
41503
- const stale = status.connected && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
41984
+ const greeted = hasSaidWhoItIs(status);
41985
+ const stale = greeted && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
41504
41986
  const unfinished = this.restartLeftUnfinished(status.connected);
41505
41987
  return {
41506
41988
  ...status,
41507
41989
  serverVersion: SERVER_VERSION,
41508
- addonIsStale: status.connected ? stale : undefined,
41990
+ addonIsStale: greeted ? stale : undefined,
41991
+ greeting: status.connected && !greeted ? "The editor has connected and has not yet said who it is: its project, pid, addon version and ports arrive with its greeting a moment after the socket opens, later while it imports. Ask again for them." : undefined,
41509
41992
  bridgeAvailable: this.bridgeStartupError === null,
41510
- mayYetConnect: status.connected ? undefined : unfinished !== null ? false : anEditorIsStillComing(status.listeningSince, launchedIsUp),
41511
- restartInterrupted: unfinished === null ? undefined : {
41512
- quitEditorPid: unfinished.editorPid,
41513
- quitAt: unfinished.quitAt,
41514
- byServerPid: unfinished.byPid,
41993
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? unfinished.underway : anEditorIsStillComing(status.listeningSince, launchedIsUp),
41994
+ restartInterrupted: unfinished === null || unfinished.underway ? undefined : {
41995
+ quitEditorPid: unfinished.note.editorPid,
41996
+ quitAt: unfinished.note.quitAt,
41997
+ byServerPid: unfinished.note.byPid,
41515
41998
  note: "A restart begun by a previous gdharness server asked this project's editor to quit, and that server was ended before it could start the editor again, so nothing is coming. editor_launch open starts one, and clears this."
41516
41999
  },
42000
+ restartUnderway: unfinished === null || !unfinished.underway ? undefined : {
42001
+ quitEditorPid: unfinished.note.editorPid,
42002
+ quitAt: unfinished.note.quitAt,
42003
+ byServerPid: unfinished.note.byPid,
42004
+ note: `A restart of this project's editor is under way: gdharness server pid ${unfinished.note.byPid} asked it to quit at ${unfinished.note.quitAt} and is starting it again. Wait for it rather than opening another.`
42005
+ },
41517
42006
  awaitingLaunchedEditor: status.connected || !launchedIsUp ? undefined : this.launchedEditor?.pid ?? undefined,
41518
42007
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
41519
42008
  startupError: this.bridgeStartupError,
@@ -41523,8 +42012,9 @@ class GodotServer {
41523
42012
  port: this.godotBridge.configuredPort,
41524
42013
  note: `The gdharness server this one replaced, pid ${this.handingOverFrom}, still holds port ${this.godotBridge.configuredPort} and is standing down; this server takes the port when it lets go, and takes another after ${HANDOVER_MS / 1000} seconds if it does not.`
41525
42014
  },
41526
- staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
41527
- addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
42015
+ staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()) : undefined,
42016
+ addonNote: greeted ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
42017
+ startedByAnEditorNote: status.startedByAnEditor === true ? "Another editor started this one: Godot's own restart, or the project manager opening a project. None of the arguments a server gave the editor before it reached this one, so it serves the ports its editor settings name (and none at all while another editor holds those, when the script and debug tools reach that editor instead), its console is not captured, and it restarts itself. Close it and use editor_launch open to have an editor this server started." : undefined,
41528
42018
  ...this.breakpointsAtRisk(),
41529
42019
  retryingBridge: this.bridgeRetry === null ? undefined : true,
41530
42020
  announcedAt: this.announcedAt ?? undefined,
@@ -41553,7 +42043,8 @@ class GodotServer {
41553
42043
  return {
41554
42044
  playing: readBoolean(asParams(answer), "playing") ?? false,
41555
42045
  scene: readString(asParams(answer), "scenePath") ?? "",
41556
- debugPort: readNumber(asParams(answer), "debugPort")
42046
+ debugPort: readNumber(asParams(answer), "debugPort"),
42047
+ play: playOf(asParams(answer))
41557
42048
  };
41558
42049
  } catch {
41559
42050
  return null;
@@ -41649,7 +42140,7 @@ class GodotServer {
41649
42140
  return null;
41650
42141
  }
41651
42142
  try {
41652
- return settingKeys(readFileSync19(join22(projectPath, "project.godot"), "utf8"));
42143
+ return settingKeys(readFileSync20(join23(projectPath, "project.godot"), "utf8"));
41653
42144
  } catch {
41654
42145
  return null;
41655
42146
  }
@@ -41662,17 +42153,33 @@ class GodotServer {
41662
42153
  "editor_status says whether the bridge is up and what has reached it"
41663
42154
  ]);
41664
42155
  }
41665
- const settingsBefore = this.settingKeysOf(before.projectPath);
42156
+ if (!hasSaidWhoItIs(before)) {
42157
+ const settled = await this.waitForBridge(() => hasSaidWhoItIs(this.godotBridge.getStatus()) || !this.godotBridge.isConnected(), Date.now() + GREETING_WAIT_MS);
42158
+ if (!settled) {
42159
+ return this.createErrorResponse(`The editor has connected and has not said who it is within ${GREETING_WAIT_MS / 1000}s, so whether this server or the editor itself restarts it cannot be told.`, [
42160
+ "editor_status says when it has, under greeting while it has not",
42161
+ "Then ask for the restart again"
42162
+ ]);
42163
+ }
42164
+ return this.handleRestartEditor(args);
42165
+ }
41666
42166
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
42167
+ if (!mine && args["hidden"] === true) {
42168
+ return this.createErrorResponse("This editor was opened by hand, and restarts itself as it was, so it cannot come back hidden.", [
42169
+ "Close it, then editor_launch open with hidden: true starts a hidden one this server can restart",
42170
+ "editor_launch restart without hidden restarts it as it is"
42171
+ ]);
42172
+ }
42173
+ const settingsBefore = this.settingKeysOf(before.projectPath);
41667
42174
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
41668
42175
  if (asked.isError === true) {
41669
42176
  return asked;
41670
42177
  }
41671
42178
  const startedAt = before.connectedAt?.getTime() ?? 0;
41672
42179
  const began = Date.now();
41673
- const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + EDITOR_RESTART_TIMEOUT_MS);
42180
+ const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + editorRestartTimeoutMs());
41674
42181
  if (!back) {
41675
- return this.createErrorResponse(`The editor was asked to restart and has not come back within ${EDITOR_RESTART_TIMEOUT_MS / 1000}s.`, [
42182
+ return this.createErrorResponse(`The editor was asked to restart and has not come back within ${editorRestartTimeoutMs() / 1000}s.`, [
41676
42183
  "It may be asking what to do about an unsaved scene: look at the editor window",
41677
42184
  "An addon that no longer parses stops the editor reaching this server",
41678
42185
  "editor_status says whether anything has reached the bridge since"
@@ -41686,18 +42193,27 @@ class GodotServer {
41686
42193
  addonVersion: now.addonVersion,
41687
42194
  serverVersion: SERVER_VERSION,
41688
42195
  addonIsStale: editorIsStale(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
41689
- staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
42196
+ staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()),
41690
42197
  ...this.whatTheEditorDropped(settingsBefore, after),
41691
42198
  tookMs: Date.now() - began
41692
42199
  });
41693
42200
  }
41694
- whatTheLaunchedEditorDropped() {
42201
+ settleTheLaunchReading() {
41695
42202
  const watched = this.launchedFrom;
41696
- if (watched === null) {
41697
- return {};
42203
+ const status = this.godotBridge.getStatus();
42204
+ if (watched === null || !hasSaidWhoItIs(status)) {
42205
+ return;
41698
42206
  }
41699
42207
  this.launchedFrom = null;
41700
- return this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
42208
+ if (status.editorPid === undefined || status.editorPid !== this.launchedEditor?.pid) {
42209
+ return;
42210
+ }
42211
+ this.launchedDropped = this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
42212
+ }
42213
+ whatTheLaunchedEditorDropped() {
42214
+ const dropped = this.launchedDropped ?? {};
42215
+ this.launchedDropped = null;
42216
+ return dropped;
41701
42217
  }
41702
42218
  whatTheEditorDropped(before, after) {
41703
42219
  return settingsDroppedReport(before, after);
@@ -41723,7 +42239,14 @@ class GodotServer {
41723
42239
  restartSettled(projectPath);
41724
42240
  return asked;
41725
42241
  }
41726
- await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
42242
+ const gone = await this.waitForBridge(() => !alive(editorPid), Date.now() + editorRestartTimeoutMs());
42243
+ if (!gone) {
42244
+ restartSettled(projectPath);
42245
+ return this.createErrorResponse(`The editor was asked to go and pid ${editorPid ?? "unknown"} is still running after ${editorRestartTimeoutMs() / 1000}s, so no replacement was started: it would find the language server and debug adapter ports still held.`, [
42246
+ "It may be asking what to do about unsaved changes, or a script may be holding up its exit",
42247
+ `editor_launch open starts one on ${projectPath} once it has gone`
42248
+ ]);
42249
+ }
41727
42250
  const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
41728
42251
  restartSettled(projectPath);
41729
42252
  if (opened.error !== null) {
@@ -41754,6 +42277,10 @@ class GodotServer {
41754
42277
  "Another project wants its own gdharness server, which is its own harness session"
41755
42278
  ]);
41756
42279
  }
42280
+ const refused = this.anEditorThatCannotConnect(project.value.path);
42281
+ if (refused !== null) {
42282
+ return refused;
42283
+ }
41757
42284
  const engine = await this.engine();
41758
42285
  if (!engine.ok) {
41759
42286
  return engine.response;
@@ -41778,6 +42305,27 @@ class GodotServer {
41778
42305
  settingsNote: before === null ? "project.godot could not be read before this editor was opened, so nothing here will be able to say what the import saved away. Opening a project rewrites the file and drops any key sitting at its own default." : before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
41779
42306
  });
41780
42307
  }
42308
+ anEditorThatCannotConnect(projectPath) {
42309
+ if (this.ownProject !== null && !isSameDirectory(this.ownProject, projectPath)) {
42310
+ return this.createErrorResponse(`This server serves ${this.ownProject}, and an editor of ${projectPath} would dial that project's own server rather than this one.`, [
42311
+ "Open it from that project's own gdharness session",
42312
+ `editor_launch with projectPath ${this.ownProject} opens the editor this server serves`
42313
+ ]);
42314
+ }
42315
+ const report = inspectProject(projectPath);
42316
+ const installed = report.addons.some((addon) => addon.name === "gdharness_editor" && addon.installed);
42317
+ if (!installed || !report.pluginsEnabled.includes("gdharness_editor")) {
42318
+ return this.createErrorResponse(`The gdharness editor addon is not ${installed ? "enabled" : "installed"} in ${projectPath}, so an editor opened on it would never reach this server.`, installed ? [
42319
+ "gdharness setup in the project enables it",
42320
+ "Or enable it under Project Settings, Plugins, in an editor opened by hand"
42321
+ ] : ["gdharness setup in the project installs and enables it"]);
42322
+ }
42323
+ const owed = restartOwed(projectPath);
42324
+ if (owed !== null && restartUnderway(owed)) {
42325
+ return this.createErrorResponse(`A restart of this project's editor is under way: gdharness server pid ${owed.byPid} asked it to quit at ${owed.quitAt} and is starting it again, so opening another would put two editors on one project.`, ["editor_status says when it has connected"]);
42326
+ }
42327
+ return null;
42328
+ }
41781
42329
  async openAnEditor(engine, projectPath, ports, hidden) {
41782
42330
  clearEditorLog(projectPath);
41783
42331
  const launched = await launchOutsideTheTree({
@@ -41812,6 +42360,13 @@ class GodotServer {
41812
42360
  if (!project.ok) {
41813
42361
  return project.response;
41814
42362
  }
42363
+ const open = this.godotBridge.isConnected() ? this.godotBridge.getStatus().projectPath : undefined;
42364
+ if (op !== "check" && open !== undefined && open !== "" && !isSameDirectory(open, project.value.path)) {
42365
+ return this.createErrorResponse(`This start names ${project.value.path}, and the editor on this bridge has ${open} open.`, [
42366
+ "editor_status names the project this server's editor is showing",
42367
+ "One server serves one editor: start a second server for the other project"
42368
+ ]);
42369
+ }
41815
42370
  const scene = readNonEmptyString(args, "scene");
41816
42371
  const sceneToRun = scene !== undefined ? resolveWithinProject(project.value.path, scene) : null;
41817
42372
  if (sceneToRun && !sceneToRun.ok) {
@@ -41906,7 +42461,7 @@ class GodotServer {
41906
42461
  }
41907
42462
  async runtimeUp(projectPath, before, budget, withArgs) {
41908
42463
  const budgetMs = budget.ms;
41909
- if (!existsSync15(join22(projectPath, RUNTIME_AUTOLOAD.path))) {
42464
+ if (!existsSync17(join23(projectPath, RUNTIME_AUTOLOAD.path))) {
41910
42465
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
41911
42466
  }
41912
42467
  let asking = null;
@@ -41915,7 +42470,7 @@ class GodotServer {
41915
42470
  let editorAsks = 0;
41916
42471
  const waitBegan = Date.now();
41917
42472
  const lastSaid = () => editorSaysGoing ? "playing" : "not playing";
41918
- const endpoint = await announcedSince(projectPath, before, {
42473
+ const fresh = await announcedSince(projectPath, before, {
41919
42474
  budgetMs,
41920
42475
  accept: (one) => this.isTheGameOf(this.activeProcess, one, projectPath),
41921
42476
  giveUp: () => {
@@ -41939,11 +42494,19 @@ class GodotServer {
41939
42494
  }
41940
42495
  });
41941
42496
  const asked = editorAsks === 0 ? "editor not asked" : `editor asked ${editorAsks === 1 ? "once" : `${editorAsks} times`}, last said ${lastSaid()}`;
41942
- this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${endpoint === null ? "nothing announced" : `pid ${endpoint.pid} announced`}, ${asked}`);
42497
+ this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${fresh.length === 0 ? "nothing announced" : `pid ${fresh.map((one) => one.pid).join(", ")} announced`}, ${asked}`);
41943
42498
  const going = await this.currentRun();
42499
+ const endpoint = await this.theRunsAmong(going, fresh);
41944
42500
  if (endpoint !== null && going !== null && going.announcedPid === undefined) {
41945
42501
  this.tie(going, endpoint);
41946
42502
  }
42503
+ if (endpoint === null && fresh.length > 1) {
42504
+ return {
42505
+ listening: true,
42506
+ pids: fresh.map((one) => one.pid),
42507
+ note: `${fresh.length} games of this project announced at once and none could be told from the process tree as this run's own game rather than one it started, so none is taken as its runtime. editor_status lists them under runtimes.`
42508
+ };
42509
+ }
41947
42510
  return runtimeVerdict(endpoint, {
41948
42511
  addon: true,
41949
42512
  budgetMs,
@@ -42027,7 +42590,13 @@ class GodotServer {
42027
42590
  };
42028
42591
  this.activeProcess = played;
42029
42592
  this.watchThePlayedConsole();
42030
- writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
42593
+ const which = playOf(playAnswer);
42594
+ writeEditorRunNote({
42595
+ projectPath,
42596
+ transcript: transcript.path,
42597
+ startedAt,
42598
+ ...which === null ? {} : { play: which }
42599
+ });
42031
42600
  sweepTranscripts();
42032
42601
  return this.jsonTextResponse({
42033
42602
  started: true,
@@ -42142,7 +42711,7 @@ class GodotServer {
42142
42711
  return 0;
42143
42712
  }
42144
42713
  try {
42145
- return reportAlreadyInTranscript(readFileSync19(game.transcript), readFileSync19(report));
42714
+ return reportAlreadyInTranscript(readFileSync20(game.transcript), readFileSync20(report));
42146
42715
  } catch (error) {
42147
42716
  this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
42148
42717
  return 0;
@@ -42156,6 +42725,18 @@ class GodotServer {
42156
42725
  game.brokeOn = halt.text;
42157
42726
  game.log.record("error", halt.text);
42158
42727
  }
42728
+ async untilTheEditorStopsPlaying(withinMs = STOP_WAIT_MS) {
42729
+ const deadline = Date.now() + withinMs;
42730
+ for (;; ) {
42731
+ if ((await this.editorPlayingState())?.playing === false) {
42732
+ return true;
42733
+ }
42734
+ if (Date.now() >= deadline) {
42735
+ return false;
42736
+ }
42737
+ await delay3(100);
42738
+ }
42739
+ }
42159
42740
  async endActiveGame(reason, going) {
42160
42741
  const running = this.activeProcess;
42161
42742
  const project = running?.projectPath ?? this.ourProject();
@@ -42174,8 +42755,16 @@ class GodotServer {
42174
42755
  running.endedHere = reason;
42175
42756
  const announced = this.announcedPidOf(running);
42176
42757
  if (running.throughEditor) {
42177
- await this.handleViaBridge("stop_playing", {});
42178
- return await untilGone(announced === undefined ? [] : [announced]) ? "gone" : "lingering";
42758
+ const asked = await this.handleViaBridge("stop_playing", {});
42759
+ if (asked.isError === true) {
42760
+ running.endedHere = null;
42761
+ running.log.record("warning", `This run was not ended here: the editor could not be asked to stop it (${asked.content[0]?.text ?? "no reason given"}).`);
42762
+ return "unreached";
42763
+ }
42764
+ if (announced === undefined) {
42765
+ return await this.untilTheEditorStopsPlaying() ? "gone" : "lingering";
42766
+ }
42767
+ return await untilGone([announced]) ? "gone" : "lingering";
42179
42768
  }
42180
42769
  if (running.pid === null) {
42181
42770
  return "gone";
@@ -42184,23 +42773,30 @@ class GodotServer {
42184
42773
  pid: running.pid,
42185
42774
  transcript: running.transcript ?? "",
42186
42775
  startedAt: running.startedAt,
42776
+ ...running.startedBy === undefined ? {} : { startedBy: running.startedBy },
42187
42777
  projectPath: running.projectPath ?? "",
42188
42778
  arguments: [],
42189
42779
  ...running.command === undefined ? {} : { command: running.command }
42190
42780
  };
42191
- if (!await stillTheRecordedRun(startedAs)) {
42781
+ const why = await whyNotStillTheRecordedRun(startedAs);
42782
+ if (why !== null) {
42192
42783
  clearTheRecord();
42193
42784
  running.endedHere = null;
42194
- running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
42785
+ running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as (${why}), so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
42195
42786
  return "refused";
42196
42787
  }
42197
42788
  const signalled = await runningAs(running.pid);
42198
42789
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
42199
42790
  let landed = true;
42791
+ let declined = null;
42200
42792
  try {
42201
42793
  process.kill(running.pid);
42202
- } catch {
42794
+ } catch (error) {
42203
42795
  landed = false;
42796
+ const code = error.code;
42797
+ if (code !== "ESRCH") {
42798
+ declined = code ?? errorMessage(error);
42799
+ }
42204
42800
  }
42205
42801
  const gone = await untilGone([running.pid]);
42206
42802
  if (gone) {
@@ -42211,11 +42807,11 @@ class GodotServer {
42211
42807
  running.exitSignal = "SIGTERM";
42212
42808
  }
42213
42809
  clearTheRecord();
42214
- running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
42810
+ running.log.record(gone ? "info" : "warning", declined !== null ? `gdharness could not signal pid ${running.pid}: the operating system refused it (${declined}). It had described that process as ${described}.` : !gone ? `gdharness signalled pid ${running.pid}, which the operating system described as ${described}, and it was still running ${STOP_WAIT_MS / 1000} seconds later.` : landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
42215
42811
  return gone ? "gone" : "lingering";
42216
42812
  }
42217
42813
  spawnGame(godotPath, cmdArgs, env) {
42218
- const child = spawn3(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
42814
+ const child = spawn4(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
42219
42815
  const cancelled = callSignal();
42220
42816
  if (cancelled !== undefined) {
42221
42817
  const end = () => {
@@ -42255,7 +42851,6 @@ class GodotServer {
42255
42851
  });
42256
42852
  child.on("exit", (code, signal) => {
42257
42853
  this.logDebug(`Godot process exited with code ${code ?? "none"}, signal ${signal ?? "none"}`);
42258
- log.finish();
42259
42854
  started.exitCode = code;
42260
42855
  started.exitSignal = code === null ? signal : null;
42261
42856
  });
@@ -42265,7 +42860,28 @@ class GodotServer {
42265
42860
  `);
42266
42861
  started.exitCode = -1;
42267
42862
  });
42268
- return started;
42863
+ const settled = new Promise((resolve) => {
42864
+ let open = 2;
42865
+ const done = () => {
42866
+ log.finish();
42867
+ resolve();
42868
+ };
42869
+ const closed = () => {
42870
+ open -= 1;
42871
+ if (open === 0) {
42872
+ done();
42873
+ }
42874
+ };
42875
+ child.stdout.once("close", closed);
42876
+ child.stderr.once("close", closed);
42877
+ child.once("exit", () => {
42878
+ setTimeout(done, STREAMS_GRACE_MS).unref();
42879
+ });
42880
+ child.once("error", () => {
42881
+ done();
42882
+ });
42883
+ });
42884
+ return Object.assign(started, { settled });
42269
42885
  }
42270
42886
  async launchKeptGame(godotPath, cmdArgs, projectPath, env, desktop) {
42271
42887
  const startedAt = Date.now();
@@ -42275,12 +42891,13 @@ class GodotServer {
42275
42891
  command: godotPath,
42276
42892
  args: cmdArgs,
42277
42893
  ...env === undefined ? {} : { env },
42278
- run: { transcript: transcript.path, startedAt, projectPath },
42894
+ run: { transcript: transcript.path, startedAt, projectPath, ...this.servedBy() },
42279
42895
  ...desktop === undefined ? {} : { desktop }
42280
42896
  });
42281
42897
  if ("error" in launched) {
42282
42898
  return launched;
42283
42899
  }
42900
+ const startedBy = Date.now();
42284
42901
  sweepTranscripts();
42285
42902
  return {
42286
42903
  pid: launched.pid,
@@ -42289,6 +42906,7 @@ class GodotServer {
42289
42906
  readOffset: 0,
42290
42907
  projectPath,
42291
42908
  startedAt,
42909
+ startedBy,
42292
42910
  command: godotPath,
42293
42911
  exitCode: null,
42294
42912
  exitSignal: null,
@@ -42347,7 +42965,8 @@ class GodotServer {
42347
42965
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
42348
42966
  }
42349
42967
  const project = this.godotBridge.getStatus().projectPath ?? null;
42350
- const note = project === null ? null : readEditorRunNote(project);
42968
+ const recorded = project === null ? null : readEditorRunNote(project);
42969
+ const note = recorded !== null && noteIsAbout(recorded, playing.play) ? recorded : null;
42351
42970
  const picked = {
42352
42971
  pid: null,
42353
42972
  log: new GameLog,
@@ -42404,6 +43023,31 @@ class GodotServer {
42404
43023
  const its = await this.tiedThroughTheTree(run, running);
42405
43024
  return its !== undefined && running.some((one) => one.pid === its) ? its : undefined;
42406
43025
  }
43026
+ async theRunsAmong(run, fresh) {
43027
+ if (fresh.length === 1) {
43028
+ return fresh[0] ?? null;
43029
+ }
43030
+ if (fresh.length === 0 || run === null) {
43031
+ return null;
43032
+ }
43033
+ const pid = run.announcedPid ?? await this.tiedThroughTheTree(run, fresh);
43034
+ return fresh.find((one) => one.pid === pid) ?? null;
43035
+ }
43036
+ async tieThePlayedGame(run) {
43037
+ if (!run.throughEditor || run.errorReport !== undefined || run.announcedPid !== undefined) {
43038
+ return;
43039
+ }
43040
+ const announced = runtimesAnnounced().running;
43041
+ if (this.announcedPidOf(run, announced) !== undefined || !stillRunning(run)) {
43042
+ return;
43043
+ }
43044
+ const among = this.announcedOfTheRunsProject(run, announced).map((one) => one.pid).sort((one, other) => one - other).join(",");
43045
+ if (among === "" || run.treeReadFor === among) {
43046
+ return;
43047
+ }
43048
+ run.treeReadFor = among;
43049
+ await this.tiedThroughTheTree(run, announced);
43050
+ }
42407
43051
  async tiedThroughTheTree(run, announced) {
42408
43052
  if (run.announcedPid !== undefined) {
42409
43053
  return run.announcedPid;
@@ -42482,8 +43126,9 @@ class GodotServer {
42482
43126
  }
42483
43127
  async adoptRecordedRun() {
42484
43128
  const project = this.ourProject();
42485
- const record = project === null ? null : readRunRecord(project);
42486
- if (record === null || !this.couldBeOurs(record.projectPath)) {
43129
+ const found = project === null ? [] : [readRunRecord(project), this.startedForUs(project)];
43130
+ const record = found.reduce((newest, one) => one !== null && (newest === null || one.startedAt > newest.startedAt) ? one : newest, null);
43131
+ if (record === null || !(this.couldBeOurs(record.projectPath) || this.couldBeOurs(record.servedBy ?? ""))) {
42487
43132
  return null;
42488
43133
  }
42489
43134
  const adopted = {
@@ -42493,6 +43138,7 @@ class GodotServer {
42493
43138
  readOffset: 0,
42494
43139
  projectPath: record.projectPath === "" ? null : record.projectPath,
42495
43140
  startedAt: record.startedAt,
43141
+ ...record.startedBy === undefined ? {} : { startedBy: record.startedBy },
42496
43142
  ...record.command === undefined ? {} : { command: record.command },
42497
43143
  exitCode: null,
42498
43144
  exitSignal: null,
@@ -42513,6 +43159,13 @@ class GodotServer {
42513
43159
  this.activeProcess = adopted;
42514
43160
  return adopted;
42515
43161
  }
43162
+ servedBy() {
43163
+ const mine = this.ourProject();
43164
+ return mine === null ? {} : { servedBy: mine };
43165
+ }
43166
+ startedForUs(project) {
43167
+ return everyRunRecord().find((one) => one.servedBy !== undefined && isSameDirectory(one.servedBy, project) && !(one.projectPath !== "" && isSameDirectory(one.projectPath, project))) ?? null;
43168
+ }
42516
43169
  nothingOfOursIsRunning() {
42517
43170
  const status = this.godotBridge.getStatus();
42518
43171
  const noEditor = !status.connected ? " No editor is connected, so a game the editor is playing cannot be seen from here at all:" + " editor_status says whether one is on its way, and a run of yours that is still up is" + " reachable again once it is." : "";
@@ -42604,6 +43257,7 @@ class GodotServer {
42604
43257
  resolve(false);
42605
43258
  });
42606
43259
  });
43260
+ await boot.settled;
42607
43261
  const errors = boot.log.count("error");
42608
43262
  const warnings = boot.log.count("warning");
42609
43263
  return this.jsonTextResponse({
@@ -42678,22 +43332,20 @@ class GodotServer {
42678
43332
  }
42679
43333
  async handleEditorConsole(args) {
42680
43334
  const status = this.godotBridge.getStatus();
42681
- const projectPath = status.projectPath ?? this.ownProject ?? undefined;
42682
- if (projectPath === undefined) {
42683
- return this.createErrorResponse("No editor is connected, so there is no console to read.", [
42684
- "editor_status says whether one is connected and whether it may yet be",
42685
- "editor_launch opens one, and an editor opened here has its console captured"
42686
- ]);
43335
+ const greeted = hasSaidWhoItIs(status);
43336
+ const launched = greeted ? null : this.launchedEditor?.pid ?? null;
43337
+ if (!greeted && launched === null && status.connected) {
43338
+ return this.createErrorResponse("The editor has connected and has not yet said who it is, so whose console to read cannot be told yet.", ["Ask again in a moment; editor_status shows greeting until it has"]);
42687
43339
  }
42688
- const launched = status.connected ? null : this.launchedEditor?.pid ?? null;
42689
- if (!status.connected && launched === null) {
43340
+ const projectPath = status.projectPath ?? this.ownProject ?? undefined;
43341
+ if (projectPath === undefined || !greeted && launched === null) {
42690
43342
  return this.createErrorResponse("No editor is connected, so there is no console to read.", [
42691
43343
  "editor_status says whether one is connected and whether it may yet be",
42692
43344
  "editor_launch opens one, and an editor opened here has its console captured"
42693
43345
  ]);
42694
43346
  }
42695
- const editorPid = status.connected ? status.editorPid ?? null : launched;
42696
- const console2 = editorConsole(projectPath, editorPid, status.connected ? status.openedByAServer === true : true);
43347
+ const editorPid = greeted ? status.editorPid ?? null : launched;
43348
+ const console2 = editorConsole(projectPath, editorPid, greeted ? status.openedByAServer === true : true);
42697
43349
  if ("kind" in console2) {
42698
43350
  return this.createErrorResponse(theConsoleWasNotCaptured(console2, projectPath), [
42699
43351
  "editor_launch restart replaces this editor with one whose console is captured",
@@ -42745,6 +43397,7 @@ class GodotServer {
42745
43397
  if (!run) {
42746
43398
  return this.createErrorResponse(this.nothingOfOursIsRunning());
42747
43399
  }
43400
+ await this.tieThePlayedGame(run);
42748
43401
  this.drainEditorOutput(run);
42749
43402
  this.drainTranscript(run);
42750
43403
  const going = await this.runStillGoing(run);
@@ -42780,7 +43433,7 @@ class GodotServer {
42780
43433
  notes.push(endedWithoutACode({
42781
43434
  throughEditor: run.throughEditor,
42782
43435
  announced: announced !== undefined || run.announcedPid !== undefined,
42783
- couldAnnounce: run.projectPath !== null && existsSync15(join22(run.projectPath, RUNTIME_AUTOLOAD.path)),
43436
+ couldAnnounce: run.projectPath !== null && existsSync17(join23(run.projectPath, RUNTIME_AUTOLOAD.path)),
42784
43437
  endedHere: typeof run.endedHere === "string",
42785
43438
  pickedUp: run.pickedUp === true
42786
43439
  }));
@@ -42788,6 +43441,9 @@ class GodotServer {
42788
43441
  if (run.pickedUpPlaying === true) {
42789
43442
  notes.push(run.transcript !== null ? "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for." : run.consoleFromReport === true ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so what is below was read back from the file the game's runtime writes its console to, which begins where the runtime started: the engine's own start-up before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
42790
43443
  }
43444
+ if (errorsUnread(run)) {
43445
+ notes.push("The editor plays this run and no report from its game's runtime addon has been found, so what the game raised with push_error and the engine's own errors are not counted here: the editor's debugger relays what a game prints and not what it reports. errors counts what was heard, and clean is left out rather than said. A game with the runtime addon writes that report once it announces itself.");
43446
+ }
42791
43447
  if (run.consoleLost === true) {
42792
43448
  const back = this.dapClient?.isConnected() === true;
42793
43449
  notes.push(`The debug adapter this run's console arrives over went away while the run was going, so what is below has a hole in it and the counts are of what was heard rather than of what was printed. ${back ? "It is connected again, and the line marking where is in the log." : "It is still gone, so nothing further will arrive here."} The project's own user://logs/godot.log has the rest, and "no entries" here means "not heard" rather than a quiet run.`);
@@ -42803,8 +43459,9 @@ class GodotServer {
42803
43459
  pid: run.pid ?? announced ?? run.announcedPid ?? null,
42804
43460
  errors: run.log.count("error"),
42805
43461
  warnings: run.log.count("warning"),
42806
- clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
43462
+ clean: cleanVerdict(run),
42807
43463
  consoleLost: run.consoleLost === true ? true : undefined,
43464
+ errorsUnread: errorsUnread(run) ? true : undefined,
42808
43465
  ...hold,
42809
43466
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
42810
43467
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
@@ -42839,6 +43496,7 @@ class GodotServer {
42839
43496
  if (elsewhere !== null) {
42840
43497
  return elsewhere;
42841
43498
  }
43499
+ await this.tieThePlayedGame(stopped);
42842
43500
  this.drainEditorOutput(stopped);
42843
43501
  this.drainTranscript(stopped);
42844
43502
  const wasRunning = await this.runStillGoing(stopped);
@@ -42847,7 +43505,7 @@ class GodotServer {
42847
43505
  const children = readBoolean(args, "andChildren") === true && wasRunning ? await this.whatTheRunStarted(stopped) : null;
42848
43506
  this.logDebug("Stopping the running game");
42849
43507
  const ending = await this.endActiveGame("editor_run stop", wasRunning);
42850
- const refused = ending === "refused";
43508
+ const refused = ending === "refused" || ending === "unreached";
42851
43509
  const ended = children === null || refused ? null : endChildrenAmong(children);
42852
43510
  if (wasRunning && !refused) {
42853
43511
  for (const pid of new Set([endedPid, announcedPid])) {
@@ -42862,13 +43520,14 @@ class GodotServer {
42862
43520
  endedPid,
42863
43521
  throughEditor: stopped.throughEditor,
42864
43522
  exitSignal: stopped.exitSignal,
42865
- errors: stopped.log.count("error")
43523
+ clean: cleanVerdict(stopped)
42866
43524
  });
42867
43525
  return this.jsonTextResponse({
42868
43526
  stopped: verdict.stopped,
42869
43527
  through: stopped.throughEditor ? "editor" : "gdharness",
42870
43528
  endedPid: verdict.endedPid,
42871
43529
  notSignalled: verdict.notSignalled,
43530
+ stillGoing: verdict.stillGoing,
42872
43531
  ...childrenFields(ended),
42873
43532
  exitedBeforeStop: !wasRunning,
42874
43533
  exitCode: stopped.exitCode,
@@ -42876,6 +43535,7 @@ class GodotServer {
42876
43535
  errors: stopped.log.count("error"),
42877
43536
  warnings: stopped.log.count("warning"),
42878
43537
  clean: verdict.clean,
43538
+ errorsUnread: errorsUnread(stopped) ? true : undefined,
42879
43539
  note: verdict.withChildren ? `${verdict.note} ${aboutTheChildren(ended, stopped.throughEditor)}` : verdict.note,
42880
43540
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
42881
43541
  });
@@ -42925,12 +43585,13 @@ class GodotServer {
42925
43585
  await announcementEnded(pid);
42926
43586
  }
42927
43587
  return this.jsonTextResponse({
42928
- stopped: true,
43588
+ stopped: gone,
42929
43589
  through: "gdharness",
42930
- endedPid: pid,
43590
+ endedPid: gone ? pid : undefined,
43591
+ stillGoing: gone ? undefined : pid,
42931
43592
  ...childrenFields(ended),
42932
43593
  exitedBeforeStop: !landed,
42933
- note: `${landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`} ${aboutTheChildren(ended, false)}`
43594
+ note: `${!gone ? `pid ${pid} was told to end and was still running ${STOP_WAIT_MS / 1000} seconds later, so it has not stopped: a game of this project that no server here was holding,` : landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : " editor_status says when it has gone."} ${aboutTheChildren(ended, false)}`
42934
43595
  });
42935
43596
  }
42936
43597
  async whatTheRunStarted(run) {
@@ -43077,7 +43738,8 @@ class GodotServer {
43077
43738
  }
43078
43739
  }
43079
43740
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
43080
- const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
43741
+ const declaredNow = before === null || after === null ? null : declaredClasses(projectPath);
43742
+ const lost = before === null || after === null || declaredNow === null ? [] : [...before.keys()].filter((name) => !after.has(name) && declaredNow.has(name));
43081
43743
  if (lost.length > 0) {
43082
43744
  this.noteAShortListEditor(projectPath);
43083
43745
  }
@@ -43088,7 +43750,12 @@ class GodotServer {
43088
43750
  const stillAtMissingPaths = atMissingPaths.filter((name) => !dropped.includes(name));
43089
43751
  const reloading = readNonEmptyString(args, "reloadScript");
43090
43752
  let reloaded = {};
43091
- if (reloading !== undefined) {
43753
+ const reloadingSource = reloading === undefined || projectPath === "" ? null : this.sourceOfClass(projectPath, reloading);
43754
+ if (reloading !== undefined && reloadingSource !== null && isToolScript(reloadingSource)) {
43755
+ reloaded = {
43756
+ problem: `${reloading} is a @tool script, which runs inside the editor, so it is not reloaded from inside a call: editor_launch restart recompiles it.`
43757
+ };
43758
+ } else if (reloading !== undefined) {
43092
43759
  try {
43093
43760
  const answer = asParams(await this.godotBridge.invokeTool("reload_script", { ...args, scriptPath: reloading }));
43094
43761
  const methods = readArray(answer, "methods");
@@ -43096,29 +43763,36 @@ class GodotServer {
43096
43763
  const found = readArray(answer, field);
43097
43764
  return found ? { [field]: found.map(String) } : {};
43098
43765
  };
43766
+ const asResource = (path) => path.startsWith("res://") ? path : `res://${path.replace(/^\/+/, "")}`;
43767
+ const script = asResource(readString(answer, "script") ?? reloading);
43768
+ const spelling = script === asResource(reloading) ? {} : { as: script };
43099
43769
  const failed = answer["failed"];
43100
43770
  if (typeof failed === "number" && failed !== 0) {
43101
43771
  reloaded = {
43772
+ ...spelling,
43102
43773
  ...listed("heldBefore"),
43103
43774
  ...listed("heldConstantsBefore"),
43104
43775
  problem: failedReloadNote({
43105
- script: reloading,
43776
+ script,
43106
43777
  code: failed,
43107
43778
  codeName: readString(answer, "failedAs") ?? "",
43108
43779
  heldBefore: listed("heldBefore")["heldBefore"] ?? [],
43109
43780
  heldAfter: methods?.map(String) ?? [],
43110
43781
  constantsBefore: listed("heldConstantsBefore")["heldConstantsBefore"] ?? [],
43111
43782
  constantsAfter: listed("constants")["constants"] ?? [],
43112
- names: projectPath === "" ? [] : this.classesAScriptNames(projectPath, reloading)
43783
+ names: projectPath === "" ? [] : this.classesAScriptNames(projectPath, script),
43784
+ restored: typeof answer["restored"] === "boolean" ? answer["restored"] : null,
43785
+ restoredAs: readString(answer, "restoredAs") ?? ""
43113
43786
  })
43114
43787
  };
43115
43788
  } else {
43116
43789
  reloaded = methods ? {
43790
+ ...spelling,
43117
43791
  methods: methods.map(String),
43118
43792
  ...listed("heldBefore"),
43119
43793
  ...listed("constants"),
43120
43794
  ...listed("heldConstantsBefore")
43121
- } : { problem: `the editor would not say what ${reloading} has after reloading it` };
43795
+ } : { problem: `the editor would not say what ${script} has after reloading it` };
43122
43796
  }
43123
43797
  } catch (error) {
43124
43798
  reloaded = { problem: `${reloading} could not be reloaded: ${errorMessage(error)}` };
@@ -43126,7 +43800,7 @@ class GodotServer {
43126
43800
  }
43127
43801
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
43128
43802
  const unseen = checked.unseen;
43129
- const broughtIn = blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
43803
+ const broughtIn = busy ? [] : blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
43130
43804
  const dependents = busy || projectPath === "" || broughtIn.length === 0 ? null : await this.reloadDependents(projectPath, broughtIn);
43131
43805
  const stillGone = lost.filter((name) => !restored.includes(name));
43132
43806
  const notes = [];
@@ -43140,7 +43814,7 @@ class GodotServer {
43140
43814
  notes.push("The scan finished and these classes are still not in the list the editor resolves against, so every use of them reads as an unknown identifier. Its walk skips a file another engine has already imported. Rescan again on its own, which is the measured cure and needs no change to the declaring script, or restart the editor with editor_launch restart.");
43141
43815
  }
43142
43816
  if (dependents !== null && dependents.notReloaded.length > 0) {
43143
- notes.push(`The scan brought in ${broughtIn.join(", ")}, and ${dependents.notReloaded.map((one) => one.scriptPath).join(", ")} ${dependents.notReloaded.length === 1 ? "names one of them and" : "name them and"} could not be reloaded, so the editor still holds what it compiled while it could not see the class and reports that to the language server. editor_rescan with reloadScript on each, or editor_launch restart.`);
43817
+ notes.push(notReloadedNote(broughtIn, dependents.notReloaded));
43144
43818
  }
43145
43819
  for (const group of uidsDuplicatedOnDisk) {
43146
43820
  notes.push(`${group.join(" and ")} name one UID in their .uid files, so Godot keeps one path for it and every engine warns "UID duplicate detected". Delete the .uid of the one that is a copy and rescan: the editor writes it a UID of its own.`);
@@ -43155,6 +43829,7 @@ class GodotServer {
43155
43829
  stillWorking: busy,
43156
43830
  waitedMs: Date.now() - started,
43157
43831
  waitedForEditorScanMs: waitedForEditorMs > 0 ? waitedForEditorMs : undefined,
43832
+ reloadedAs: reloaded.as,
43158
43833
  heldBeforeReload: reloaded.heldBefore,
43159
43834
  reloadedMethods: reloaded.methods,
43160
43835
  heldConstantsBeforeReload: reloaded.heldConstantsBefore,
@@ -43181,7 +43856,8 @@ class GodotServer {
43181
43856
  if (tool) {
43182
43857
  notReloaded.push({
43183
43858
  scriptPath,
43184
- problem: "a @tool script, which runs inside the editor, so it is not reloaded from here; editor_launch restart recompiles it"
43859
+ problem: "a @tool script, which runs inside the editor, so it is not reloaded from here; editor_launch restart recompiles it",
43860
+ tool: true
43185
43861
  });
43186
43862
  continue;
43187
43863
  }
@@ -43189,9 +43865,10 @@ class GodotServer {
43189
43865
  const answer = asParams(await this.godotBridge.invokeTool("reload_script", { scriptPath }));
43190
43866
  const failed = answer["failed"];
43191
43867
  if (typeof failed === "number" && failed !== 0) {
43868
+ const left = answer["restored"] === true ? "" : answer["restored"] === false ? "; compiling it again from the text it was built from failed too, so the editor holds it unusable until it compiles" : "; a failed compile leaves the copy the editor holds unusable until it compiles, and this editor does not put it back";
43192
43869
  notReloaded.push({
43193
43870
  scriptPath,
43194
- problem: `it did not compile: Godot answered error ${failed}${readString(answer, "failedAs") ? `, ${readString(answer, "failedAs")}` : ""}`
43871
+ problem: `it did not compile: Godot answered error ${failed}${readString(answer, "failedAs") ? `, ${readString(answer, "failedAs")}` : ""}${left}`
43195
43872
  });
43196
43873
  } else if (readArray(answer, "methods") === undefined) {
43197
43874
  notReloaded.push({
@@ -43263,6 +43940,35 @@ class GodotServer {
43263
43940
  }
43264
43941
  };
43265
43942
  }
43943
+ async handleCreateScript(args) {
43944
+ const project = this.project(args);
43945
+ if (!project.ok) {
43946
+ return project.response;
43947
+ }
43948
+ const contained = this.containProjectFiles(args);
43949
+ if (!contained.ok) {
43950
+ return contained.response;
43951
+ }
43952
+ const { op: _op, projectPath: _projectPath, ...params } = contained.value;
43953
+ const created = await this.operation("create_script", params, project.value.path);
43954
+ const declared = created.ok ? readString(created.payload, "class_name") ?? "" : "";
43955
+ if (!created.ok || declared === "") {
43956
+ return this.answer(created);
43957
+ }
43958
+ const rebuilt = await this.rebuildClassCache(project.value.path);
43959
+ const registered = cachedClasses(project.value.path)?.get(declared) === readString(created.payload, "full_path");
43960
+ const answered = this.answer({
43961
+ ...created,
43962
+ payload: {
43963
+ ...created.payload,
43964
+ registered,
43965
+ ...registered ? {} : {
43966
+ registrationNote: rebuilt.ok ? `The class list was rebuilt and does not hold ${declared} at this script, so nothing started next resolves it by name. project_import refresh_classes says what the list holds and why.` : `The class list could not be rebuilt, so nothing started next resolves ${declared} by name: ${rebuilt.message}`
43967
+ }
43968
+ }
43969
+ });
43970
+ return this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
43971
+ }
43266
43972
  async alsoSayWhatTheEditorCannotSee(answered, args) {
43267
43973
  const first = answered.content[0];
43268
43974
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -43335,7 +44041,7 @@ class GodotServer {
43335
44041
  ]);
43336
44042
  }
43337
44043
  try {
43338
- return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
44044
+ return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
43339
44045
  } catch (error) {
43340
44046
  return this.createErrorResponse(`The editor answered ${toolName} with an error: ${errorMessage(error)}`);
43341
44047
  }
@@ -43391,7 +44097,7 @@ class GodotServer {
43391
44097
  async ownRunOnItsWay(projectPath, budgetMs) {
43392
44098
  const run = await this.currentRun();
43393
44099
  const project = projectPath ?? run?.projectPath ?? null;
43394
- if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync15(join22(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
44100
+ if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync17(join23(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
43395
44101
  return null;
43396
44102
  }
43397
44103
  const already = runtimesAnnounced().running;
@@ -43415,11 +44121,15 @@ class GodotServer {
43415
44121
  const seconds = Math.round((Date.now() - run.startedAt) / 1000);
43416
44122
  return `The game this server started (${which}) has been running for ${seconds}s and has not announced its runtime, so there is nothing to talk to. A game that keeps the runtime off, as a project's own loader can for some scenes, never announces; editor_output says what it has printed.`;
43417
44123
  }
43418
- const endpoint = await announcedSince(project, new Set(already.map((one) => one.pid)), {
44124
+ const fresh = await announcedSince(project, new Set(already.map((one) => one.pid)), {
43419
44125
  budgetMs: Math.min(budgetMs, remaining),
43420
44126
  accept: (one) => this.isTheGameOf(this.activeProcess, one, project),
43421
44127
  giveUp: () => this.activeProcess !== run || this.dapClient?.isStopped() === true || !stillRunning(run)
43422
44128
  });
44129
+ const endpoint = await this.theRunsAmong(run, fresh);
44130
+ if (fresh.length > 0 && endpoint === null) {
44131
+ return null;
44132
+ }
43423
44133
  if (endpoint !== null) {
43424
44134
  const tiedMeanwhile = () => run.announcedPid !== undefined;
43425
44135
  if (!tiedMeanwhile()) {
@@ -43471,7 +44181,7 @@ class GodotServer {
43471
44181
  }
43472
44182
  }
43473
44183
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
43474
- const screenshotPath = screenshotDir ? join22(screenshotDir, "capture.png") : null;
44184
+ const screenshotPath = screenshotDir ? join23(screenshotDir, "capture.png") : null;
43475
44185
  try {
43476
44186
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
43477
44187
  if (!reply.ok && reply.requestId !== undefined) {
@@ -43512,7 +44222,7 @@ class GodotServer {
43512
44222
  type: "text",
43513
44223
  text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
43514
44224
  },
43515
- { type: "image", data: readFileSync19(screenshotPath).toString("base64"), mimeType: "image/png" }
44225
+ { type: "image", data: readFileSync20(screenshotPath).toString("base64"), mimeType: "image/png" }
43516
44226
  ]
43517
44227
  };
43518
44228
  } finally {
@@ -43609,10 +44319,11 @@ class GodotServer {
43609
44319
  }
43610
44320
  }
43611
44321
  async function runGodotServer() {
44322
+ delete process.env[EDITOR_PID_VARIABLE];
43612
44323
  const server = new GodotServer;
43613
44324
  await server.run();
43614
44325
  }
43615
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
44326
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, GREETING_WAIT_MS = 1e4, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
43616
44327
  var init_server3 = __esm(() => {
43617
44328
  init_mcp();
43618
44329
  init_stdio2();
@@ -43628,6 +44339,7 @@ var init_server3 = __esm(() => {
43628
44339
  init_dap_client();
43629
44340
  init_desktop();
43630
44341
  init_editor_log();
44342
+ init_engine_run();
43631
44343
  init_errors();
43632
44344
  init_game_log();
43633
44345
  init_godot_bridge();
@@ -43652,8 +44364,8 @@ var init_server3 = __esm(() => {
43652
44364
  init_setup();
43653
44365
  init_tool_definitions();
43654
44366
  init_update_check();
43655
- run5 = promisify6(execFile6);
43656
- __dirname2 = dirname15(fileURLToPath5(import.meta.url));
44367
+ run4 = promisify5(execFile5);
44368
+ __dirname2 = dirname16(fileURLToPath5(import.meta.url));
43657
44369
  CONSOLE_WRAPPER = /_console(\.exe)?$/i;
43658
44370
  LONGEST_BOOT_NOTED_MS = 5 * 60000;
43659
44371
  VALUE_NAMES = {
@@ -43702,8 +44414,8 @@ var init_server3 = __esm(() => {
43702
44414
  init_bridge_announce();
43703
44415
  init_errors();
43704
44416
  init_godot_path();
43705
- import { existsSync as existsSync16 } from "node:fs";
43706
- import { join as join23, resolve as resolve6 } from "node:path";
44417
+ import { existsSync as existsSync18 } from "node:fs";
44418
+ import { join as join24, resolve as resolve6 } from "node:path";
43707
44419
 
43708
44420
  // src/harnesses.ts
43709
44421
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
@@ -44413,27 +45125,37 @@ function registered(harness, projectPath) {
44413
45125
  }
44414
45126
  function recordedEnginePath(projectPath) {
44415
45127
  for (const harness of HARNESSES) {
44416
- if (harness.scope !== "project" || harness.toml || harness.yaml || harness.snippet) {
45128
+ if (harness.scope !== "project") {
44417
45129
  continue;
44418
45130
  }
44419
- const path = configPath(harness, projectPath);
44420
- if (!existsSync4(path)) {
44421
- continue;
44422
- }
44423
- let parsed;
44424
- try {
44425
- parsed = JSON.parse(readFileSync2(path, "utf8"));
44426
- } catch {
44427
- continue;
44428
- }
44429
- const entry = reach(reach(parsed, harness.container), SERVER_KEY);
44430
- const named = reach(reach(entry, harness.shape === "opencode" ? "environment" : "env"), "GODOT_PATH");
45131
+ const named = reach(entryEnvironment(harness, projectPath), "GODOT_PATH");
44431
45132
  if (typeof named === "string" && named !== "" && existsSync4(named)) {
44432
45133
  return named;
44433
45134
  }
44434
45135
  }
44435
45136
  return null;
44436
45137
  }
45138
+ function entryEnvironment(harness, projectPath) {
45139
+ if (harness.toml || harness.yaml || harness.snippet) {
45140
+ return;
45141
+ }
45142
+ const path = configPath(harness, projectPath);
45143
+ if (!existsSync4(path)) {
45144
+ return;
45145
+ }
45146
+ let parsed;
45147
+ try {
45148
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
45149
+ } catch {
45150
+ return;
45151
+ }
45152
+ const entry = reach(reach(parsed, harness.container), SERVER_KEY);
45153
+ return reach(entry, harness.shape === "opencode" ? "environment" : "env");
45154
+ }
45155
+ function servedProject(harness, projectPath) {
45156
+ const named = reach(entryEnvironment(harness, projectPath), "GDHARNESS_PROJECT");
45157
+ return typeof named === "string" && named !== "" ? named : null;
45158
+ }
44437
45159
  function reach(held, key) {
44438
45160
  return typeof held === "object" && held !== null && !Array.isArray(held) ? held[key] : undefined;
44439
45161
  }
@@ -44571,6 +45293,7 @@ function taken(harness, path, held, stripped) {
44571
45293
  // src/cli.ts
44572
45294
  init_headless();
44573
45295
  init_issues();
45296
+ init_paths();
44574
45297
 
44575
45298
  // src/prompt.ts
44576
45299
  import process8 from "node:process";
@@ -44602,8 +45325,8 @@ init_setup();
44602
45325
  // src/skill.ts
44603
45326
  init_setup();
44604
45327
  init_tool_definitions();
44605
- import { existsSync as existsSync10, mkdirSync as mkdirSync5, readdirSync as readdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
44606
- import { basename as basename3, dirname as dirname5, join as join11 } from "node:path";
45328
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as readdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
45329
+ import { basename as basename3, dirname as dirname6, join as join11 } from "node:path";
44607
45330
 
44608
45331
  // src/tool-reference.ts
44609
45332
  init_headless_operations();
@@ -44736,9 +45459,10 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
44736
45459
  - Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
44737
45460
  with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
44738
45461
  from being known. For a run the editor plays, the errors come from the game through the runtime
44739
- addon, so a played game without the addon is judged on its prints alone and \`clean\` there says
44740
- less. For a long run it also names a transcript file: read that for output and this
44741
- answer for state. Never decide a run has ended by watching the file stop growing, or a process
45462
+ addon, so a played game without the addon answers \`errorsUnread\` and no \`clean\`, since
45463
+ what it raised reached nothing the server reads. For a long run it also names a transcript file:
45464
+ read that for output and this answer for state. Never decide a run has ended by watching the
45465
+ file stop growing, or a process
44742
45466
  list, or a timeout; a bench between prints looks exactly like a bench that died to everything else.
44743
45467
  \`endedBy\` says whether gdharness ended it, and \`running\` is asked of the operating system
44744
45468
  whenever there is a process to ask about: every run started through \`editor_run\`, and every
@@ -44768,7 +45492,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
44768
45492
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
44769
45493
  | What a property reads right now, on a node whose path you already hold | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
44770
45494
  | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
44771
- | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments and walks into what it returned |
45495
+ | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments that only reads and walks into what it returned |
44772
45496
  | Press a button | \`runtime_input click\` with \`says\` and the words on it, which finds it and presses it in the same frame and says what was under the pointer. A \`find\` and then a click by the path it answered is two calls over a generated path, and a panel that rebuilds between them frees the button first; \`nodePath\` narrows where \`says\` looks, and several matches are refused with an \`index\` for each |
44773
45497
  | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows (no \`nodePath\` needed: without one the whole game is searched), then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
44774
45498
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
@@ -44918,9 +45642,9 @@ function everySkillDirectory(projectPath, harnesses) {
44918
45642
  function removeSkill(directories, projectPath) {
44919
45643
  const removed = [];
44920
45644
  for (const directory of directories) {
44921
- if (existsSync10(directory)) {
45645
+ if (existsSync11(directory)) {
44922
45646
  rmSync6(directory, { recursive: true, force: true });
44923
- pruneEmpty(dirname5(directory), projectPath);
45647
+ pruneEmpty(dirname6(directory), projectPath);
44924
45648
  removed.push(directory);
44925
45649
  }
44926
45650
  }
@@ -44934,17 +45658,17 @@ function pruneEmpty(directory, projectPath) {
44934
45658
  return;
44935
45659
  }
44936
45660
  rmSync6(path, { recursive: true, force: true });
44937
- path = dirname5(path);
45661
+ path = dirname6(path);
44938
45662
  }
44939
45663
  }
44940
45664
  function writeSkill(directories, version) {
44941
45665
  const files = skillFiles(version);
44942
45666
  return directories.map((directory) => {
44943
- const replaced = existsSync10(directory);
45667
+ const replaced = existsSync11(directory);
44944
45668
  rmSync6(directory, { recursive: true, force: true });
44945
45669
  for (const [name, contents] of files) {
44946
45670
  const path = join11(directory, name);
44947
- mkdirSync5(dirname5(path), { recursive: true });
45671
+ mkdirSync5(dirname6(path), { recursive: true });
44948
45672
  writeFileSync7(path, contents, "utf8");
44949
45673
  }
44950
45674
  return { path: directory, replaced };
@@ -44952,18 +45676,18 @@ function writeSkill(directories, version) {
44952
45676
  }
44953
45677
 
44954
45678
  // src/version.ts
44955
- import { readFileSync as readFileSync9 } from "node:fs";
44956
- import { dirname as dirname6, join as join12 } from "node:path";
45679
+ import { readFileSync as readFileSync10 } from "node:fs";
45680
+ import { dirname as dirname7, join as join12 } from "node:path";
44957
45681
  import { fileURLToPath as fileURLToPath2 } from "node:url";
44958
45682
  function getLocalVersion() {
44959
- const currentDirectory = dirname6(fileURLToPath2(import.meta.url));
45683
+ const currentDirectory = dirname7(fileURLToPath2(import.meta.url));
44960
45684
  const packageCandidates = [
44961
45685
  join12(currentDirectory, "..", "package.json"),
44962
45686
  join12(currentDirectory, "..", "..", "package.json")
44963
45687
  ];
44964
45688
  for (const packagePath of packageCandidates) {
44965
45689
  try {
44966
- const value = JSON.parse(readFileSync9(packagePath, "utf-8"));
45690
+ const value = JSON.parse(readFileSync10(packagePath, "utf-8"));
44967
45691
  if (typeof value === "object" && value !== null && "name" in value && value.name === "gdharness" && "version" in value && typeof value.version === "string") {
44968
45692
  return value.version;
44969
45693
  }
@@ -44981,7 +45705,7 @@ class UsageError extends Refusal {
44981
45705
  function projectArgument(at) {
44982
45706
  const named = args.slice(at).find((value) => !value.startsWith("--"));
44983
45707
  const projectPath = resolve6(named ?? ".");
44984
- if (!existsSync16(join23(projectPath, "project.godot"))) {
45708
+ if (!existsSync18(join24(projectPath, "project.godot"))) {
44985
45709
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
44986
45710
  }
44987
45711
  return projectPath;
@@ -45020,7 +45744,7 @@ async function registerRuntime(godot, projectPath) {
45020
45744
  return;
45021
45745
  }
45022
45746
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
45023
- if (!existsSync16(join23(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
45747
+ if (!existsSync18(join24(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
45024
45748
  console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
45025
45749
  }
45026
45750
  }
@@ -45152,7 +45876,7 @@ async function setup() {
45152
45876
  console.log("no harness configured; name one yourself, and gdharness harnesses lists them all");
45153
45877
  }
45154
45878
  if (!args.includes("--no-skill")) {
45155
- const directories = skillDirectories(chosen.harnesses, projectPath, existsSync16);
45879
+ const directories = skillDirectories(chosen.harnesses, projectPath, existsSync18);
45156
45880
  for (const written of writeSkill(directories, getLocalVersion())) {
45157
45881
  console.log(`skill: ${written.replaced ? "replaced" : "written"} ${written.path}`);
45158
45882
  }
@@ -45169,12 +45893,14 @@ async function uninstall() {
45169
45893
  const named = namedHarnesses();
45170
45894
  const from = named.length > 0 ? named : HARNESSES;
45171
45895
  for (const harness of from) {
45172
- const removal = disconnect(harness, projectPath);
45173
- if (removal.action === "absent") {
45896
+ if (harness.scope === "home" && named.length === 0) {
45897
+ if (registered(harness, projectPath)) {
45898
+ console.log(`${harness.name}: left alone. Its config is machine-wide and may serve another project; pass --${harness.id} to remove it.`);
45899
+ }
45174
45900
  continue;
45175
45901
  }
45176
- if (harness.scope === "home" && named.length === 0) {
45177
- console.log(`${harness.name}: left alone. Its config is machine-wide and may serve another project; pass --${harness.id} to remove it.`);
45902
+ const removal = disconnect(harness, projectPath);
45903
+ if (removal.action === "absent") {
45178
45904
  continue;
45179
45905
  }
45180
45906
  if (removal.action === "manual") {
@@ -45194,15 +45920,19 @@ async function uninstall() {
45194
45920
  said2(outcome, `disabling ${name}`);
45195
45921
  console.log(`${name}: ${outcome.ok ? String(outcome.payload["action"]) : "failed"}`);
45196
45922
  }
45197
- if (before.runtimeAutoload) {
45923
+ if (before.runtimeAutoloadPath !== null) {
45198
45924
  said2(await setRuntime(godot, projectPath, false), "removing the runtime autoload");
45199
45925
  console.log(`${RUNTIME_AUTOLOAD.name} autoload removed`);
45200
45926
  }
45927
+ const loader = before.runtimeLoaderAutoload;
45928
+ if (loader !== null && loader.name !== RUNTIME_AUTOLOAD.name) {
45929
+ console.log(`${loader.name} names res://${loader.path}, which brings the runtime up its own way. That line is this project's, so it is left in place; the addon it brings up is gone now, so take the entry out unless that file does nothing without it.`);
45930
+ }
45201
45931
  const addons = removeAddons(projectPath);
45202
45932
  for (const path of addons) {
45203
45933
  console.log(`removed ${path}`);
45204
45934
  }
45205
- const anything = addons.length > 0 || before.runtimeAutoload || enabled.length > 0;
45935
+ const anything = addons.length > 0 || before.runtimeAutoloadPath !== null || enabled.length > 0;
45206
45936
  console.log(anything ? `
45207
45937
  Reconnect your harness so it stops spawning a server that is no longer installed.` : `
45208
45938
  Nothing of gdharness was in this project.`);
@@ -45211,13 +45941,13 @@ async function upgrade() {
45211
45941
  const projectPath = projectArgument(1);
45212
45942
  const version = getLocalVersion();
45213
45943
  const before = inspectProject(projectPath);
45214
- const installed = before.addons.find((addon) => addon.installed)?.version ?? null;
45215
- if (installed === null) {
45944
+ const present = before.addons.find((addon) => addon.installed);
45945
+ if (present === undefined) {
45216
45946
  const where = projectPath === process.cwd() ? "" : ` ${projectPath}`;
45217
45947
  throw new UsageError(`gdharness is not installed in ${projectPath}. Run gdharness setup${where} instead.`);
45218
45948
  }
45219
45949
  const godot = await engine(projectPath);
45220
- console.log(installed === version ? `already ${version}; reinstalling` : `${installed} -> ${version}`);
45950
+ console.log(present.version === version ? `already ${version}; reinstalling` : `${present.version ?? "an unmarked copy"} -> ${version}`);
45221
45951
  const digestBefore = installedEditorDigest(projectPath);
45222
45952
  for (const addon of installAddons(projectPath)) {
45223
45953
  console.log(`${addon.replaced ? "replaced" : "installed"} ${addon.path}`);
@@ -45231,7 +45961,21 @@ async function upgrade() {
45231
45961
  }
45232
45962
  said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
45233
45963
  const launch = launchFor(version, godot.godotPath, projectPath);
45234
- const already = HARNESSES.filter((harness) => registered(harness, projectPath));
45964
+ const named = namedHarnesses();
45965
+ const already = HARNESSES.filter((harness) => {
45966
+ if (!registered(harness, projectPath)) {
45967
+ return false;
45968
+ }
45969
+ if (harness.scope !== "home" || named.includes(harness)) {
45970
+ return true;
45971
+ }
45972
+ const serves = servedProject(harness, projectPath);
45973
+ if (serves !== null && isSameDirectory(serves, projectPath)) {
45974
+ return true;
45975
+ }
45976
+ console.log(`${harness.name}: left alone. Its config is machine-wide and ${serves === null ? "names no project" : `serves ${serves}`}; pass --${harness.id} to point it at this one.`);
45977
+ return false;
45978
+ });
45235
45979
  const moved = [];
45236
45980
  let written = 0;
45237
45981
  let byHand = 0;
@@ -45246,7 +45990,7 @@ async function upgrade() {
45246
45990
  moved.push(took.from);
45247
45991
  }
45248
45992
  }
45249
- for (const written of writeSkill(skillDirectories(already, projectPath, existsSync16), version)) {
45993
+ for (const written of writeSkill(skillDirectories(already, projectPath, existsSync18), version)) {
45250
45994
  console.log(`skill: ${written.replaced ? "replaced" : "written"} ${written.path}`);
45251
45995
  }
45252
45996
  console.log(`