gdharness 0.9.7 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -7474,6 +7474,9 @@ import { join as join6 } from "node:path";
7474
7474
  function declaredClasses(projectPath) {
7475
7475
  const declared = new Map;
7476
7476
  const visit = (directory, prefix) => {
7477
+ if (existsSync6(join6(directory, ".gdignore"))) {
7478
+ return;
7479
+ }
7477
7480
  for (const entry of readdirSync2(directory, { withFileTypes: true })) {
7478
7481
  if (entry.name.startsWith(".")) {
7479
7482
  continue;
@@ -7511,6 +7514,12 @@ function staleClassNames(projectPath) {
7511
7514
  const cached = cachedClasses(projectPath);
7512
7515
  return cached === null ? [...declaredClasses(projectPath).keys()] : staleAgainst(cached, projectPath);
7513
7516
  }
7517
+ function unseenByEditor(projectPath, editorHolds) {
7518
+ const held = new Set(editorHolds);
7519
+ const declared = declaredClasses(projectPath);
7520
+ const source = cachedClasses(projectPath) ?? declared;
7521
+ return [...source].filter(([name]) => !held.has(name) && declared.has(name)).map(([className, path]) => ({ className, path: declared.get(className) ?? path }));
7522
+ }
7514
7523
  var init_class_cache = () => {};
7515
7524
 
7516
7525
  // src/headless.ts
@@ -15371,7 +15380,7 @@ var init_tool_definitions = __esm(() => {
15371
15380
  uid: { summary: "the UID of one file", requires: ["resourcePath"] },
15372
15381
  refresh_uids: { summary: "resave every resource so UID references are current", requires: [] },
15373
15382
  refresh_classes: {
15374
- summary: "rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, for an editor whose list has gone stale",
15383
+ summary: 'rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, and name under unseenByEditor any class the editor open on this project still cannot resolve: rewriting the file does not reach the list a running editor loaded, so "added: []" means the file was already right rather than that nothing is wrong',
15375
15384
  requires: []
15376
15385
  }
15377
15386
  }
@@ -15671,7 +15680,7 @@ var init_tool_definitions = __esm(() => {
15671
15680
  },
15672
15681
  {
15673
15682
  name: "editor_run",
15674
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
15683
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
15675
15684
  parameters: {
15676
15685
  projectPath: PROJECT_PATH,
15677
15686
  scene: { type: "string", description: "A scene to run instead of the main scene." },
@@ -15716,7 +15725,7 @@ var init_tool_definitions = __esm(() => {
15716
15725
  },
15717
15726
  {
15718
15727
  name: "editor_rescan",
15719
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file the editor has already recorded and reads as unchanged is one it does not look inside again: a class_name usually becomes visible this way and on one project did not, with the cache on disk still missing it after the scan said it was done. project_import refresh_classes is the one that rebuilds the class list from the declarations on disk, whatever the editor thinks it has seen. Needs the editor connected.",
15728
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is a change to the declaring script, or editor_launch restart; project_import refresh_classes rewrites the cache and does not touch what the editor is holding. Needs the editor connected.",
15720
15729
  parameters: {
15721
15730
  projectPath: PROJECT_PATH,
15722
15731
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -35771,6 +35780,7 @@ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync a
35771
35780
  import { createConnection as createConnection3 } from "node:net";
35772
35781
  import { tmpdir as tmpdir4 } from "node:os";
35773
35782
  import { join as join13, resolve as resolve4 } from "node:path";
35783
+ import { setTimeout as delay2 } from "node:timers/promises";
35774
35784
  function runtimeDirectory(variables = process.env) {
35775
35785
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
35776
35786
  if (explicit) {
@@ -35859,6 +35869,22 @@ function announcedIn(directory) {
35859
35869
  }
35860
35870
  return found;
35861
35871
  }
35872
+ async function announcedSince(projectPath, before, waiting = {}) {
35873
+ const wanted = resolve4(projectPath);
35874
+ const directories = waiting.directories ?? runtimeDirectories();
35875
+ const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
35876
+ for (;; ) {
35877
+ const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve4(endpoint.project.path) === wanted);
35878
+ if (fresh !== undefined) {
35879
+ return fresh;
35880
+ }
35881
+ const left = until - Date.now();
35882
+ if (left <= 0 || waiting.giveUp?.() === true) {
35883
+ return null;
35884
+ }
35885
+ await delay2(Math.min(LOOK_EVERY_MS, left));
35886
+ }
35887
+ }
35862
35888
  function describe2(endpoint) {
35863
35889
  return `pid ${endpoint.pid} on ${endpoint.address}:${endpoint.port} (${endpoint.project.name || "unnamed"} at ${endpoint.project.path})`;
35864
35890
  }
@@ -35985,7 +36011,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
35985
36011
  });
35986
36012
  });
35987
36013
  }
35988
- var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, nextRequestId = 1;
36014
+ var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
35989
36015
  var init_runtime_client = __esm(() => {
35990
36016
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
35991
36017
  });
@@ -36001,13 +36027,17 @@ function installedAddonVersion(projectPath) {
36001
36027
  const said = readFileSync12(marker, "utf8").trim();
36002
36028
  return said === "" ? null : said;
36003
36029
  }
36004
- var ADDONS2, VERSION_MARKER2 = ".gdharness-version";
36030
+ var ADDONS2, RUNTIME_AUTOLOAD2, VERSION_MARKER2 = ".gdharness-version";
36005
36031
  var init_setup = __esm(() => {
36006
36032
  init_class_cache();
36007
36033
  init_headless();
36008
36034
  init_resources();
36009
36035
  init_server_version();
36010
36036
  ADDONS2 = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
36037
+ RUNTIME_AUTOLOAD2 = {
36038
+ name: "GdharnessRuntime",
36039
+ path: "addons/gdharness_runtime/runtime_autoload.gd"
36040
+ };
36011
36041
  });
36012
36042
 
36013
36043
  // src/server.ts
@@ -36015,7 +36045,7 @@ import { execFile as execFile5, spawn } from "node:child_process";
36015
36045
  import { existsSync as existsSync12, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
36016
36046
  import { tmpdir as tmpdir5 } from "node:os";
36017
36047
  import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
36018
- import { setTimeout as delay2 } from "node:timers/promises";
36048
+ import { setTimeout as delay3 } from "node:timers/promises";
36019
36049
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36020
36050
  import { promisify as promisify5 } from "node:util";
36021
36051
  function patienceForFrames(frames, atLeast) {
@@ -36092,6 +36122,10 @@ function realPathOr(path) {
36092
36122
  return path;
36093
36123
  }
36094
36124
  }
36125
+ function samePath(one, other) {
36126
+ const settled = (path) => realPathOr(path).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
36127
+ return settled(one) === settled(other);
36128
+ }
36095
36129
  function aboveTheRunner(entry) {
36096
36130
  const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
36097
36131
  if (runner < 0) {
@@ -36522,7 +36556,8 @@ class GodotServer {
36522
36556
  async dispatch(tool, op, args) {
36523
36557
  const headless = HEADLESS_OPERATIONS[tool]?.[op];
36524
36558
  if (headless !== undefined) {
36525
- return await this.headless(headless, args);
36559
+ const answered = await this.headless(headless, args);
36560
+ return headless === "refresh_class_cache" && this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
36526
36561
  }
36527
36562
  const { op: _op, ...arguments_ } = args;
36528
36563
  const bridge = async (command, extra = {}) => {
@@ -37327,7 +37362,7 @@ class GodotServer {
37327
37362
  if (Date.now() >= deadline) {
37328
37363
  return false;
37329
37364
  }
37330
- await delay2(250);
37365
+ await delay3(250);
37331
37366
  }
37332
37367
  }
37333
37368
  async handleLaunchEditor(args) {
@@ -37423,12 +37458,13 @@ class GodotServer {
37423
37458
  this.logDebug("Ending the running game before starting another");
37424
37459
  await this.endActiveGame();
37425
37460
  }
37461
+ const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
37426
37462
  const headless = resolveHeadless(args["headless"], {
37427
37463
  platform: process.platform,
37428
37464
  variables: process.env
37429
37465
  });
37430
37466
  if (this.godotBridge.isConnected() && (!headless || editorPlaysHeadless(project.value.file))) {
37431
- return await this.playThroughEditor(sceneArgument, refreshed.value);
37467
+ return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
37432
37468
  }
37433
37469
  const cmdArgs = runArguments({
37434
37470
  projectPath: project.value.path,
@@ -37444,10 +37480,31 @@ class GodotServer {
37444
37480
  pid: started.process.pid ?? null,
37445
37481
  arguments: cmdArgs,
37446
37482
  refreshedClasses: refreshed.value,
37483
+ runtime: await this.runtimeUp(project.value.path, alreadyPlaying),
37447
37484
  message: "Use editor_output for what it prints and editor_run stop to end it."
37448
37485
  });
37449
37486
  }
37450
- async playThroughEditor(scene, refreshedClasses) {
37487
+ async runtimeUp(projectPath, before) {
37488
+ if (!existsSync12(join15(projectPath, RUNTIME_AUTOLOAD2.path))) {
37489
+ return {
37490
+ listening: false,
37491
+ note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
37492
+ };
37493
+ }
37494
+ const endpoint = await announcedSince(projectPath, before, {
37495
+ giveUp: () => this.dapClient?.isStopped() === true
37496
+ });
37497
+ if (endpoint === null) {
37498
+ const held = this.dapClient?.whereItStopped() ?? null;
37499
+ return {
37500
+ listening: false,
37501
+ note: held === null ? `nothing announced itself within ${ANNOUNCE_BUDGET_MS}ms: the game may be slow to boot, or its runtime is not coming up` : "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
37502
+ heldAt: held
37503
+ };
37504
+ }
37505
+ return { listening: true, pid: endpoint.pid, port: endpoint.port };
37506
+ }
37507
+ async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying) {
37451
37508
  const log = new GameLog;
37452
37509
  try {
37453
37510
  await this.dap().connect();
@@ -37476,6 +37533,7 @@ class GodotServer {
37476
37533
  scene: scene === null ? "the main scene" : `res://${scene}`,
37477
37534
  refreshedClasses,
37478
37535
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
37536
+ runtime: await this.runtimeUp(projectPath, alreadyPlaying),
37479
37537
  message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
37480
37538
  });
37481
37539
  }
@@ -37630,13 +37688,59 @@ class GodotServer {
37630
37688
  const status = asParams(await this.godotBridge.invokeTool("rescan_filesystem", { ...args, statusOnly: true }));
37631
37689
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]);
37632
37690
  }
37691
+ const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
37692
+ const unseen = checked.unseen;
37633
37693
  return this.jsonTextResponse({
37634
- ok: !busy,
37694
+ ok: !busy && unseen.length === 0 && checked.unchecked === undefined,
37635
37695
  stillWorking: busy,
37636
37696
  waitedMs: Date.now() - started,
37637
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : undefined
37697
+ unseenByEditor: unseen.length > 0 ? unseen : undefined,
37698
+ classesUnchecked: checked.unchecked,
37699
+ note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : unseen.length > 0 ? "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. Change the declaring script and rescan, or restart the editor with editor_launch restart." : undefined
37638
37700
  });
37639
37701
  }
37702
+ async alsoSayWhatTheEditorCannotSee(answered, args) {
37703
+ const first = answered.content[0];
37704
+ if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
37705
+ return answered;
37706
+ }
37707
+ const checked = await this.classesTheEditorCannotSee(args);
37708
+ if (checked.unseen.length === 0 && checked.unchecked === undefined) {
37709
+ return answered;
37710
+ }
37711
+ return this.jsonTextResponse({
37712
+ ...asParams(JSON.parse(first.text)),
37713
+ unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
37714
+ classesUnchecked: checked.unchecked,
37715
+ note: checked.unseen.length > 0 ? "The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. Change the declaring script and editor_rescan, or restart the editor with editor_launch restart." : undefined
37716
+ });
37717
+ }
37718
+ async classesTheEditorCannotSee(args) {
37719
+ const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
37720
+ if (projectPath === "") {
37721
+ return { unseen: [], unchecked: "no projectPath, so there was nothing to read the cache from" };
37722
+ }
37723
+ const open = this.godotBridge.getStatus().projectPath;
37724
+ if (open === undefined || !samePath(open, projectPath)) {
37725
+ return {
37726
+ unseen: [],
37727
+ unchecked: `the connected editor is open on ${open ?? "a project it did not name"}, not this one`
37728
+ };
37729
+ }
37730
+ let held;
37731
+ try {
37732
+ held = asParams(await this.godotBridge.invokeTool("global_classes", args));
37733
+ } catch (error) {
37734
+ return {
37735
+ unseen: [],
37736
+ unchecked: `the editor would not say what classes it is holding: ${errorMessage(error)}`
37737
+ };
37738
+ }
37739
+ if (!Array.isArray(held["classes"])) {
37740
+ return { unseen: [], unchecked: "the editor answered without a class list in it" };
37741
+ }
37742
+ return { unseen: unseenByEditor(projectPath, held["classes"].map(String)) };
37743
+ }
37640
37744
  async handleViaBridge(toolName, args) {
37641
37745
  if (!this.godotBridge.isConnected()) {
37642
37746
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
@@ -39226,6 +39330,12 @@ formatting. \`script_diagnostics\` and \`script_info\` come from the editor's ow
39226
39330
  \`editor_classes\` need nothing open: they run a short headless engine and are gone before the
39227
39331
  answer is printed.
39228
39332
 
39333
+ A wall of "Could not find type" on the project's own classes means the editor is not holding
39334
+ them. \`editor_rescan\` asks it to scan and names any class it still cannot resolve under
39335
+ \`unseenByEditor\`: its walk skips a file a headless engine has already imported, so the
39336
+ declaration and the cache can both be right while the editor stays blind to it. Change the
39337
+ declaring script, or restart the editor.
39338
+
39229
39339
  ## Refusals are useful
39230
39340
 
39231
39341
  An unknown op, an argument the tool does not name, or a missing required one is refused with the
@@ -9,6 +9,7 @@ const SceneTools = preload("tools/scene_tools.gd")
9
9
  const ResourceTools = preload("tools/resource_tools.gd")
10
10
  const AnimationTools = preload("tools/animation_tools.gd")
11
11
  const PlayTools = preload("tools/play_tools.gd")
12
+ const ClassTools = preload("tools/class_tools.gd")
12
13
 
13
14
  var _editor_plugin: EditorPlugin = null
14
15
 
@@ -16,6 +17,7 @@ var _scene_tools: SceneTools = null
16
17
  var _resource_tools: ResourceTools = null
17
18
  var _animation_tools: AnimationTools = null
18
19
  var _play_tools: PlayTools = null
20
+ var _class_tools: ClassTools = null
19
21
 
20
22
  var _tool_map: Dictionary = {}
21
23
  var _initialized: bool = false
@@ -28,6 +30,7 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
28
30
  _resource_tools.set_editor_plugin(plugin)
29
31
  _animation_tools.set_editor_plugin(plugin)
30
32
  _play_tools.set_editor_plugin(plugin)
33
+ _class_tools.set_editor_plugin(plugin)
31
34
 
32
35
 
33
36
  func _init_tools() -> void:
@@ -51,6 +54,10 @@ func _init_tools() -> void:
51
54
  _play_tools.name = "PlayTools"
52
55
  add_child(_play_tools)
53
56
 
57
+ _class_tools = ClassTools.new()
58
+ _class_tools.name = "ClassTools"
59
+ add_child(_class_tools)
60
+
54
61
  _tool_map = {
55
62
  # Scene tools
56
63
  "create_scene": [_scene_tools, "create_scene"],
@@ -66,6 +73,7 @@ func _init_tools() -> void:
66
73
  "disconnect_signal": [_scene_tools, "disconnect_signal"],
67
74
  "list_connections": [_scene_tools, "list_connections"],
68
75
  "rescan_filesystem": [_scene_tools, "rescan_filesystem"],
76
+ "global_classes": [_class_tools, "global_classes"],
69
77
  # Resource tools
70
78
  "create_resource": [_resource_tools, "create_resource"],
71
79
  "modify_resource": [_resource_tools, "modify_resource"],
@@ -0,0 +1,56 @@
1
+ @tool
2
+ extends Node
3
+
4
+ ## The global classes the running editor is holding.
5
+ ##
6
+ ## The editor fixes this list when it starts and adds to it only from a filesystem scan, and
7
+ ## that scan is change-detecting: a file another engine has already imported looks settled, so
8
+ ## the walk goes past it without reading the declaration inside. The class is then declared on
9
+ ## disk, listed in `.godot/global_script_class_cache.cfg`, and absent from the editor, whose
10
+ ## analyser reports every use of it as an unknown identifier. Rebuilding the cache does not
11
+ ## help, because nothing rereads it.
12
+ ##
13
+ ## Every check there was compares one file on disk against another, so all of them call that
14
+ ## state clean. The editor is the only thing that can say otherwise, so it is asked.
15
+ ##
16
+ ## Read off the editor's own filesystem entries rather than from
17
+ ## `ProjectSettings.get_global_class_list()`. The entries are what the scan itself recorded, so
18
+ ## nothing but a scan can change them; the settings list is loaded from the cache file when it is
19
+ ## first asked for, which makes it a poor witness against a cache that was just rewritten, and
20
+ ## rewriting the cache is the call most in need of an honest answer here.
21
+ ##
22
+ ## A file the scan skipped is either missing from the tree or recorded with no class name against
23
+ ## it, and both come back as a class the editor does not have, which is the whole of the question.
24
+
25
+ var _editor_plugin: EditorPlugin = null
26
+
27
+
28
+ func set_editor_plugin(plugin: EditorPlugin) -> void:
29
+ _editor_plugin = plugin
30
+
31
+
32
+ func global_classes(_args: Dictionary) -> Dictionary:
33
+ if not _editor_plugin:
34
+ return {"ok": false, "error": "Editor plugin unavailable"}
35
+
36
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
37
+ var names: PackedStringArray = []
38
+ _walk(filesystem.get_filesystem(), names)
39
+ names.sort()
40
+ return {
41
+ "ok": true,
42
+ "classes": names,
43
+ "scanning": filesystem.is_scanning(),
44
+ "importing": filesystem.is_importing(),
45
+ }
46
+
47
+
48
+ func _walk(directory: EditorFileSystemDirectory, into: PackedStringArray) -> void:
49
+ if directory == null:
50
+ return
51
+ for index: int in range(directory.get_file_count()):
52
+ var declared: String = directory.get_file_script_class_name(index)
53
+ if not declared.is_empty():
54
+ into.append(declared)
55
+ for index: int in range(directory.get_subdir_count()):
56
+ _walk(directory.get_subdir(index), into)
@@ -0,0 +1 @@
1
+ uid://bodtvclhs3toc
@@ -45,6 +45,17 @@ func set_project_setting(params: Dictionary) -> Dictionary:
45
45
  old_value = ProjectSettings.get_setting(setting_path)
46
46
 
47
47
  var final_value: Variant = _values.deserialize_value(value)
48
+ var wanted: int = _wanted_type(setting_path, old_value if had_value else null)
49
+ if wanted > TYPE_NIL and final_value != null and typeof(final_value) != wanted:
50
+ if not _converts_faithfully(final_value, wanted):
51
+ return _log.failure(
52
+ (
53
+ "%s is declared as %s, and %s (%s) cannot be written as that without losing something."
54
+ % [setting_path, type_string(wanted), str(final_value), type_string(typeof(final_value))]
55
+ )
56
+ )
57
+ final_value = type_convert(final_value, wanted)
58
+
48
59
  ProjectSettings.set_setting(setting_path, final_value)
49
60
  var err: Error = ProjectSettings.save()
50
61
  if err != OK:
@@ -59,6 +70,33 @@ func set_project_setting(params: Dictionary) -> Dictionary:
59
70
  }
60
71
 
61
72
 
73
+ # The type a setting wants, or TYPE_NIL where the engine names none and there is nothing to go on.
74
+ #
75
+ # JSON carries one number type, so every number arrives as a float and an int setting was written
76
+ # to project.godot as "50.0". Every reader casts it back, so the tool, the editor and the running
77
+ # game all answer 50 afterwards and only the committed file says otherwise, where it reads as
78
+ # something having gone wrong.
79
+ #
80
+ # The engine's own property list is what is asked, rather than the value that is there: a setting
81
+ # already written as "50.0" reads back as a float, so the value on hand agrees with the mistake and
82
+ # no project that has made it once could be set right again. The property list still says int.
83
+ # A setting the engine does not declare has only its current value to go on, and a new one has
84
+ # neither, which is the caller's type to choose.
85
+ func _wanted_type(setting_path: String, current: Variant) -> int:
86
+ for info: Dictionary in ProjectSettings.get_property_list():
87
+ if str(info.get("name", "")) == setting_path:
88
+ return int(info.get("type", TYPE_NIL))
89
+ return typeof(current)
90
+
91
+
92
+ # What separates a narrowing from a guess: 50.0 through int and out again is 50.0, so it was an int
93
+ # all along, while 50.5 comes back as 50.0 and "loud" comes back as "0". Those are a caller meaning
94
+ # something the setting cannot hold, and are refused rather than written as whatever the cast
95
+ # happened to produce.
96
+ func _converts_faithfully(value: Variant, to: int) -> bool:
97
+ return type_convert(type_convert(value, to), typeof(value)) == value
98
+
99
+
62
100
  # Add an autoload singleton
63
101
  func add_autoload(params: Dictionary) -> Dictionary:
64
102
  var name: String = str(params.get("name", ""))
package/build/index.js CHANGED
@@ -10129,7 +10129,7 @@ import { execFile as execFile3, spawn } from "node:child_process";
10129
10129
  import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10130
10130
  import { tmpdir as tmpdir4 } from "node:os";
10131
10131
  import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
10132
- import { setTimeout as delay2 } from "node:timers/promises";
10132
+ import { setTimeout as delay3 } from "node:timers/promises";
10133
10133
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10134
10134
  import { promisify as promisify3 } from "node:util";
10135
10135
 
@@ -24758,6 +24758,9 @@ import { join as join3 } from "node:path";
24758
24758
  function declaredClasses(projectPath) {
24759
24759
  const declared = new Map;
24760
24760
  const visit = (directory, prefix) => {
24761
+ if (existsSync2(join3(directory, ".gdignore"))) {
24762
+ return;
24763
+ }
24761
24764
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
24762
24765
  if (entry.name.startsWith(".")) {
24763
24766
  continue;
@@ -24795,6 +24798,12 @@ function staleClassNames(projectPath) {
24795
24798
  const cached = cachedClasses(projectPath);
24796
24799
  return cached === null ? [...declaredClasses(projectPath).keys()] : staleAgainst(cached, projectPath);
24797
24800
  }
24801
+ function unseenByEditor(projectPath, editorHolds) {
24802
+ const held = new Set(editorHolds);
24803
+ const declared = declaredClasses(projectPath);
24804
+ const source = cachedClasses(projectPath) ?? declared;
24805
+ return [...source].filter(([name]) => !held.has(name) && declared.has(name)).map(([className, path]) => ({ className, path: declared.get(className) ?? path }));
24806
+ }
24798
24807
 
24799
24808
  // src/dap_client.ts
24800
24809
  import { createConnection } from "node:net";
@@ -27408,6 +27417,7 @@ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as
27408
27417
  import { createConnection as createConnection3 } from "node:net";
27409
27418
  import { tmpdir as tmpdir3 } from "node:os";
27410
27419
  import { join as join7, resolve as resolve4 } from "node:path";
27420
+ import { setTimeout as delay2 } from "node:timers/promises";
27411
27421
 
27412
27422
  // src/tool-args.ts
27413
27423
  function asParams(value) {
@@ -27551,6 +27561,24 @@ function announcedIn(directory) {
27551
27561
  }
27552
27562
  return found;
27553
27563
  }
27564
+ var LOOK_EVERY_MS = 50;
27565
+ var ANNOUNCE_BUDGET_MS = 5000;
27566
+ async function announcedSince(projectPath, before, waiting = {}) {
27567
+ const wanted = resolve4(projectPath);
27568
+ const directories = waiting.directories ?? runtimeDirectories();
27569
+ const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
27570
+ for (;; ) {
27571
+ const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && resolve4(endpoint.project.path) === wanted);
27572
+ if (fresh !== undefined) {
27573
+ return fresh;
27574
+ }
27575
+ const left = until - Date.now();
27576
+ if (left <= 0 || waiting.giveUp?.() === true) {
27577
+ return null;
27578
+ }
27579
+ await delay2(Math.min(LOOK_EVERY_MS, left));
27580
+ }
27581
+ }
27554
27582
  function describe2(endpoint) {
27555
27583
  return `pid ${endpoint.pid} on ${endpoint.address}:${endpoint.port} (${endpoint.project.name || "unnamed"} at ${endpoint.project.path})`;
27556
27584
  }
@@ -27683,6 +27711,10 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
27683
27711
  import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27684
27712
  import { dirname as dirname3, join as join8 } from "node:path";
27685
27713
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27714
+ var RUNTIME_AUTOLOAD = {
27715
+ name: "GdharnessRuntime",
27716
+ path: "addons/gdharness_runtime/runtime_autoload.gd"
27717
+ };
27686
27718
  var VERSION_MARKER = ".gdharness-version";
27687
27719
  function installedAddonVersion(projectPath) {
27688
27720
  const marker = join8(projectPath, "addons", ADDONS[0], VERSION_MARKER);
@@ -27981,7 +28013,7 @@ var TOOL_SPECS = [
27981
28013
  uid: { summary: "the UID of one file", requires: ["resourcePath"] },
27982
28014
  refresh_uids: { summary: "resave every resource so UID references are current", requires: [] },
27983
28015
  refresh_classes: {
27984
- summary: "rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, for an editor whose list has gone stale",
28016
+ summary: 'rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, and name under unseenByEditor any class the editor open on this project still cannot resolve: rewriting the file does not reach the list a running editor loaded, so "added: []" means the file was already right rather than that nothing is wrong',
27985
28017
  requires: []
27986
28018
  }
27987
28019
  }
@@ -28281,7 +28313,7 @@ var TOOL_SPECS = [
28281
28313
  },
28282
28314
  {
28283
28315
  name: "editor_run",
28284
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
28316
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
28285
28317
  parameters: {
28286
28318
  projectPath: PROJECT_PATH,
28287
28319
  scene: { type: "string", description: "A scene to run instead of the main scene." },
@@ -28326,7 +28358,7 @@ var TOOL_SPECS = [
28326
28358
  },
28327
28359
  {
28328
28360
  name: "editor_rescan",
28329
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file the editor has already recorded and reads as unchanged is one it does not look inside again: a class_name usually becomes visible this way and on one project did not, with the cache on disk still missing it after the scan said it was done. project_import refresh_classes is the one that rebuilds the class list from the declarations on disk, whatever the editor thinks it has seen. Needs the editor connected.",
28361
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is a change to the declaring script, or editor_launch restart; project_import refresh_classes rewrites the cache and does not touch what the editor is holding. Needs the editor connected.",
28330
28362
  parameters: {
28331
28363
  projectPath: PROJECT_PATH,
28332
28364
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -28871,6 +28903,10 @@ function realPathOr(path) {
28871
28903
  return path;
28872
28904
  }
28873
28905
  }
28906
+ function samePath(one, other) {
28907
+ const settled = (path) => realPathOr(path).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
28908
+ return settled(one) === settled(other);
28909
+ }
28874
28910
  var RUNNER_DIRECTORY = "addons/gdUnit4/";
28875
28911
  function aboveTheRunner(entry) {
28876
28912
  const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
@@ -29302,7 +29338,8 @@ class GodotServer {
29302
29338
  async dispatch(tool, op, args) {
29303
29339
  const headless = HEADLESS_OPERATIONS[tool]?.[op];
29304
29340
  if (headless !== undefined) {
29305
- return await this.headless(headless, args);
29341
+ const answered = await this.headless(headless, args);
29342
+ return headless === "refresh_class_cache" && this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
29306
29343
  }
29307
29344
  const { op: _op, ...arguments_ } = args;
29308
29345
  const bridge = async (command, extra = {}) => {
@@ -30107,7 +30144,7 @@ class GodotServer {
30107
30144
  if (Date.now() >= deadline) {
30108
30145
  return false;
30109
30146
  }
30110
- await delay2(250);
30147
+ await delay3(250);
30111
30148
  }
30112
30149
  }
30113
30150
  async handleLaunchEditor(args) {
@@ -30203,12 +30240,13 @@ class GodotServer {
30203
30240
  this.logDebug("Ending the running game before starting another");
30204
30241
  await this.endActiveGame();
30205
30242
  }
30243
+ const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
30206
30244
  const headless = resolveHeadless(args["headless"], {
30207
30245
  platform: process.platform,
30208
30246
  variables: process.env
30209
30247
  });
30210
30248
  if (this.godotBridge.isConnected() && (!headless || editorPlaysHeadless(project.value.file))) {
30211
- return await this.playThroughEditor(sceneArgument, refreshed.value);
30249
+ return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
30212
30250
  }
30213
30251
  const cmdArgs = runArguments({
30214
30252
  projectPath: project.value.path,
@@ -30224,10 +30262,31 @@ class GodotServer {
30224
30262
  pid: started.process.pid ?? null,
30225
30263
  arguments: cmdArgs,
30226
30264
  refreshedClasses: refreshed.value,
30265
+ runtime: await this.runtimeUp(project.value.path, alreadyPlaying),
30227
30266
  message: "Use editor_output for what it prints and editor_run stop to end it."
30228
30267
  });
30229
30268
  }
30230
- async playThroughEditor(scene, refreshedClasses) {
30269
+ async runtimeUp(projectPath, before) {
30270
+ if (!existsSync7(join9(projectPath, RUNTIME_AUTOLOAD.path))) {
30271
+ return {
30272
+ listening: false,
30273
+ note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
30274
+ };
30275
+ }
30276
+ const endpoint = await announcedSince(projectPath, before, {
30277
+ giveUp: () => this.dapClient?.isStopped() === true
30278
+ });
30279
+ if (endpoint === null) {
30280
+ const held = this.dapClient?.whereItStopped() ?? null;
30281
+ return {
30282
+ listening: false,
30283
+ note: held === null ? `nothing announced itself within ${ANNOUNCE_BUDGET_MS}ms: the game may be slow to boot, or its runtime is not coming up` : "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
30284
+ heldAt: held
30285
+ };
30286
+ }
30287
+ return { listening: true, pid: endpoint.pid, port: endpoint.port };
30288
+ }
30289
+ async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying) {
30231
30290
  const log = new GameLog;
30232
30291
  try {
30233
30292
  await this.dap().connect();
@@ -30256,6 +30315,7 @@ class GodotServer {
30256
30315
  scene: scene === null ? "the main scene" : `res://${scene}`,
30257
30316
  refreshedClasses,
30258
30317
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
30318
+ runtime: await this.runtimeUp(projectPath, alreadyPlaying),
30259
30319
  message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
30260
30320
  });
30261
30321
  }
@@ -30410,13 +30470,59 @@ class GodotServer {
30410
30470
  const status = asParams(await this.godotBridge.invokeTool("rescan_filesystem", { ...args, statusOnly: true }));
30411
30471
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]);
30412
30472
  }
30473
+ const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
30474
+ const unseen = checked.unseen;
30413
30475
  return this.jsonTextResponse({
30414
- ok: !busy,
30476
+ ok: !busy && unseen.length === 0 && checked.unchecked === undefined,
30415
30477
  stillWorking: busy,
30416
30478
  waitedMs: Date.now() - started,
30417
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : undefined
30479
+ unseenByEditor: unseen.length > 0 ? unseen : undefined,
30480
+ classesUnchecked: checked.unchecked,
30481
+ note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : unseen.length > 0 ? "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. Change the declaring script and rescan, or restart the editor with editor_launch restart." : undefined
30418
30482
  });
30419
30483
  }
30484
+ async alsoSayWhatTheEditorCannotSee(answered, args) {
30485
+ const first = answered.content[0];
30486
+ if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
30487
+ return answered;
30488
+ }
30489
+ const checked = await this.classesTheEditorCannotSee(args);
30490
+ if (checked.unseen.length === 0 && checked.unchecked === undefined) {
30491
+ return answered;
30492
+ }
30493
+ return this.jsonTextResponse({
30494
+ ...asParams(JSON.parse(first.text)),
30495
+ unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
30496
+ classesUnchecked: checked.unchecked,
30497
+ note: checked.unseen.length > 0 ? "The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. Change the declaring script and editor_rescan, or restart the editor with editor_launch restart." : undefined
30498
+ });
30499
+ }
30500
+ async classesTheEditorCannotSee(args) {
30501
+ const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
30502
+ if (projectPath === "") {
30503
+ return { unseen: [], unchecked: "no projectPath, so there was nothing to read the cache from" };
30504
+ }
30505
+ const open = this.godotBridge.getStatus().projectPath;
30506
+ if (open === undefined || !samePath(open, projectPath)) {
30507
+ return {
30508
+ unseen: [],
30509
+ unchecked: `the connected editor is open on ${open ?? "a project it did not name"}, not this one`
30510
+ };
30511
+ }
30512
+ let held;
30513
+ try {
30514
+ held = asParams(await this.godotBridge.invokeTool("global_classes", args));
30515
+ } catch (error) {
30516
+ return {
30517
+ unseen: [],
30518
+ unchecked: `the editor would not say what classes it is holding: ${errorMessage(error)}`
30519
+ };
30520
+ }
30521
+ if (!Array.isArray(held["classes"])) {
30522
+ return { unseen: [], unchecked: "the editor answered without a class list in it" };
30523
+ }
30524
+ return { unseen: unseenByEditor(projectPath, held["classes"].map(String)) };
30525
+ }
30420
30526
  async handleViaBridge(toolName, args) {
30421
30527
  if (!this.godotBridge.isConnected()) {
30422
30528
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.7",
3
+ "version": "0.10.1",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",