gdharness 1.1.16 → 1.1.18

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
@@ -8013,7 +8013,28 @@ function reason(stdout, stderr) {
8013
8013
  }
8014
8014
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
8015
8015
  }
8016
- async function runImport(godotPath, projectPath) {
8016
+ function librariesNotCopied(messages) {
8017
+ const found = new Set;
8018
+ for (const entry of messages) {
8019
+ const library = /Error copying library: (.+)$/.exec(entry.text)?.[1]?.trim();
8020
+ if (library !== undefined && library !== "") {
8021
+ found.add(library);
8022
+ }
8023
+ }
8024
+ return [...found];
8025
+ }
8026
+ function extensionRetryNote(libraries) {
8027
+ return `The first import could not load ${libraries.join(" and ")}: on Windows an engine running as the editor ` + "loads a GDExtension from a ~ copy beside the library, and the editor open on this project holds its own " + "copy there, so the first engine started beside it cannot make one. That failed attempt moves the " + "editor's copy aside, so the import was run again and loaded the extension, and the messages are from " + "that second run. The editor's copy is left beside the library as a ~RF….TMP file, which can be deleted " + "once the editor has exited.";
8028
+ }
8029
+ async function runImport(godotPath, projectPath, once = importOnce) {
8030
+ const first = await once(godotPath, projectPath);
8031
+ const collided = librariesNotCopied(first.messages);
8032
+ if (collided.length === 0) {
8033
+ return first;
8034
+ }
8035
+ return { ...await once(godotPath, projectPath), librariesRetried: collided };
8036
+ }
8037
+ async function importOnce(godotPath, projectPath) {
8017
8038
  const logDir = scratchDirectory("import");
8018
8039
  try {
8019
8040
  const { stderr } = await run3(godotPath, ["--headless", "--log-file", join6(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
@@ -8706,6 +8727,18 @@ function classRemedy(entries) {
8706
8727
  const reload = declaring.length === 1 ? `editor_rescan with reloadScript set to ${declaring[0]}` : `editor_rescan with reloadScript, a call each for ${declaring.join(" and ")} since it takes one script,`;
8707
8728
  return `For ${entries.map(written).join(", ")}, run ${reload} first. In the one project that has ` + "reproduced a stale enum member, twice, the reload cleared it at once both times, and the one " + "plain editor_rescan tried left the diagnostic standing.";
8708
8729
  }
8730
+ function failedReloadNote(failed) {
8731
+ const missing = (before, after) => before.filter((name) => !after.includes(name));
8732
+ const lost = [
8733
+ ...missing(failed.heldBefore, failed.heldAfter),
8734
+ ...missing(failed.constantsBefore, failed.constantsAfter)
8735
+ ];
8736
+ const unchanged = lost.length === 0 && missing(failed.heldAfter, failed.heldBefore).length === 0 && missing(failed.constantsAfter, failed.constantsBefore).length === 0;
8737
+ const answered = failed.codeName === "" ? `error ${failed.code}` : `error ${failed.code}, ${failed.codeName}`;
8738
+ const copy = unchanged ? "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.`;
8739
+ 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.`;
8740
+ return `${failed.script} did not compile, so nothing was reloaded: Godot answered ${answered}. ${copy} ` + `script_diagnostics on it gives the errors.${order}`;
8741
+ }
8709
8742
  function uncachedClassNote(types) {
8710
8743
  if (types.length === 0) {
8711
8744
  return "";
@@ -17331,7 +17364,7 @@ var init_tool_definitions = __esm(() => {
17331
17364
  },
17332
17365
  {
17333
17366
  name: "editor_rescan",
17334
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. A scan or an import the editor is already running is waited out first rather than scanned over, which would start a second import over it, and the answer says how long under waitedForEditorScanMs. 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 this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A class the scan brings in is named under broughtIn, and every script naming it is reloaded in the editor and named under dependentsReloaded: what the editor compiled from them while it could not see the class stays compiled that way after the scan, and a script extending a new base class went on reporting \"Could not find base class\" until reloaded. One that would not reload is named under dependentsNotReloaded and the call is not ok. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. The other direction is guarded the same way: a class the editor still holds for a script that was renamed or deleted comes back into the cache at a path that is not there, and the next engine to read it fails on \"Could not parse global class\" in whichever correct script shares the bare name, so the cache is rebuilt without it and the answer names it under cacheDropped; the editor goes on holding it until it is restarted, and every scan until then ends in that rebuild. A script or shader written outside the editor has no .uid sidecar until something imports it, and the scan writes one beside it, which a project that commits sidecars needs before the commit. Needs the editor connected.",
17367
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. A scan or an import the editor is already running is waited out first rather than scanned over, which would start a second import over it, and the answer says how long under waitedForEditorScanMs. 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 this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. The walk also passes over a file whose .uid alone changed, so the editor keeps the old UID and every engine reading its cache warns of a duplicate; before scanning, each file whose .uid names a UID the editor does not hold for it is read again and named under uidsReread, and files on disk naming one UID between them are named under uidsDuplicatedOnDisk, which reading again cannot settle. A class the scan brings in is named under broughtIn, and every script naming it is reloaded in the editor and named under dependentsReloaded: what the editor compiled from them while it could not see the class stays compiled that way after the scan, and a script extending a new base class went on reporting \"Could not find base class\" until reloaded. One that would not reload is named under dependentsNotReloaded and the call is not ok, as it is not when the script named in reloadScript was not reloaded. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. The other direction is guarded the same way: a class the editor still holds for a script that was renamed or deleted comes back into the cache at a path that is not there, and the next engine to read it fails on \"Could not parse global class\" in whichever correct script shares the bare name, so the cache is rebuilt without it and the answer names it under cacheDropped; the editor goes on holding it until it is restarted, and every scan until then ends in that rebuild. A script or shader written outside the editor has no .uid sidecar until something imports it, and the scan writes one beside it, which a project that commits sidecars needs before the commit. Needs the editor connected.",
17335
17368
  parameters: {
17336
17369
  projectPath: PROJECT_PATH,
17337
17370
  timeoutMs: {
@@ -17341,7 +17374,7 @@ var init_tool_definitions = __esm(() => {
17341
17374
  },
17342
17375
  reloadScript: {
17343
17376
  type: "string",
17344
- 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."
17377
+ 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."
17345
17378
  }
17346
17379
  },
17347
17380
  requires: ["projectPath"]
@@ -17499,7 +17532,7 @@ var init_tool_definitions = __esm(() => {
17499
17532
  property: {
17500
17533
  type: "string",
17501
17534
  ops: ["set"],
17502
- 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.'
17535
+ 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.'
17503
17536
  },
17504
17537
  value: {
17505
17538
  ops: ["set"],
@@ -17719,7 +17752,10 @@ var init_tool_definitions = __esm(() => {
17719
17752
  ops: ["until"],
17720
17753
  description: 'until: the property name, or a colon path through what the node holds, "_game:run:day", read the way runtime_inspect property reads one, a step written as a call included. Walked again on every frame, so a holder the game replaces while the wait is on is followed.'
17721
17754
  },
17722
- value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
17755
+ value: {
17756
+ ops: ["until"],
17757
+ description: "until: the value to wait for, fitted to the property's type. On a property declared to hold an object, null, which the text null also means; any other value is refused, since the property can never hold it."
17758
+ },
17723
17759
  says: {
17724
17760
  type: "string",
17725
17761
  ops: ["until"],
@@ -40613,7 +40649,8 @@ class GodotServer {
40613
40649
  payload: {
40614
40650
  uidsCreated: given,
40615
40651
  stillWithoutUid: after,
40616
- note: uidsLeftNote(after.length)
40652
+ note: uidsLeftNote(after.length),
40653
+ ...imported.librariesRetried === undefined ? {} : { extensionNote: extensionRetryNote(imported.librariesRetried) }
40617
40654
  }
40618
40655
  }, scanned));
40619
40656
  }
@@ -40769,6 +40806,9 @@ class GodotServer {
40769
40806
  if (imported.messages.length > 0) {
40770
40807
  extra["engine_messages"] = imported.messages;
40771
40808
  }
40809
+ if (imported.librariesRetried !== undefined) {
40810
+ extra["extensionNote"] = extensionRetryNote(imported.librariesRetried);
40811
+ }
40772
40812
  }
40773
40813
  const after = await this.operation("get_import_status", resourcePath === undefined ? { includeUpToDate: true } : { resourcePath }, projectPath);
40774
40814
  if (!after.ok) {
@@ -41297,6 +41337,11 @@ class GodotServer {
41297
41337
  shortListEditorPid: this.godotBridge.getStatus().editorPid ?? null
41298
41338
  });
41299
41339
  }
41340
+ classesAScriptNames(projectPath, scriptPath) {
41341
+ const source = this.sourceOfClass(projectPath, scriptPath);
41342
+ const known = cachedClasses(projectPath) ?? declaredClasses(projectPath);
41343
+ return source === null ? [] : classesNamedIn(source, known).filter((name) => known.get(name) !== scriptPath);
41344
+ }
41300
41345
  sourceOfClass(projectPath, resourcePath) {
41301
41346
  const contained = resolveWithinProject(projectPath, resourcePath);
41302
41347
  if (!contained.ok || !existsSync15(contained.absolutePath)) {
@@ -42962,6 +43007,8 @@ class GodotServer {
42962
43007
  let waitedForEditorMs = 0;
42963
43008
  let first;
42964
43009
  let completedBefore;
43010
+ let uidsReread = [];
43011
+ let uidsDuplicatedOnDisk = [];
42965
43012
  for (;; ) {
42966
43013
  const busySince = Date.now();
42967
43014
  let wasBusy = false;
@@ -42978,6 +43025,11 @@ class GodotServer {
42978
43025
  }
42979
43026
  const firstAnswer = asParams(JSON.parse(first.content[0]?.text ?? "{}"));
42980
43027
  completedBefore = readNumber(firstAnswer, "scansCompletedBefore");
43028
+ uidsReread = [...uidsReread, ...(readArray(firstAnswer, "uidsReread") ?? []).map(String)];
43029
+ const duplicated = readArray(firstAnswer, "uidsDuplicatedOnDisk");
43030
+ if (duplicated !== undefined) {
43031
+ uidsDuplicatedOnDisk = duplicated.map((group) => Array.isArray(group) ? group.map(String) : []);
43032
+ }
42981
43033
  if (firstAnswer["started"] !== false || Date.now() - started >= timeoutMs) {
42982
43034
  break;
42983
43035
  }
@@ -43027,12 +43079,30 @@ class GodotServer {
43027
43079
  const found = readArray(answer, field);
43028
43080
  return found ? { [field]: found.map(String) } : {};
43029
43081
  };
43030
- reloaded = methods ? {
43031
- methods: methods.map(String),
43032
- ...listed("heldBefore"),
43033
- ...listed("constants"),
43034
- ...listed("heldConstantsBefore")
43035
- } : { problem: `the editor would not say what ${reloading} has after reloading it` };
43082
+ const failed = answer["failed"];
43083
+ if (typeof failed === "number" && failed !== 0) {
43084
+ reloaded = {
43085
+ ...listed("heldBefore"),
43086
+ ...listed("heldConstantsBefore"),
43087
+ problem: failedReloadNote({
43088
+ script: reloading,
43089
+ code: failed,
43090
+ codeName: readString(answer, "failedAs") ?? "",
43091
+ heldBefore: listed("heldBefore")["heldBefore"] ?? [],
43092
+ heldAfter: methods?.map(String) ?? [],
43093
+ constantsBefore: listed("heldConstantsBefore")["heldConstantsBefore"] ?? [],
43094
+ constantsAfter: listed("constants")["constants"] ?? [],
43095
+ names: projectPath === "" ? [] : this.classesAScriptNames(projectPath, reloading)
43096
+ })
43097
+ };
43098
+ } else {
43099
+ reloaded = methods ? {
43100
+ methods: methods.map(String),
43101
+ ...listed("heldBefore"),
43102
+ ...listed("constants"),
43103
+ ...listed("heldConstantsBefore")
43104
+ } : { problem: `the editor would not say what ${reloading} has after reloading it` };
43105
+ }
43036
43106
  } catch (error) {
43037
43107
  reloaded = { problem: `${reloading} could not be reloaded: ${errorMessage(error)}` };
43038
43108
  }
@@ -43055,13 +43125,16 @@ class GodotServer {
43055
43125
  if (dependents !== null && dependents.notReloaded.length > 0) {
43056
43126
  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.`);
43057
43127
  }
43128
+ for (const group of uidsDuplicatedOnDisk) {
43129
+ 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.`);
43130
+ }
43058
43131
  if (stillAtMissingPaths.length > 0) {
43059
43132
  notes.push(`The scan wrote the class cache with ${stillAtMissingPaths.join(", ")} at a path that is not on disk, and rebuilding the cache from the files did not take it out. The next engine to read the cache fails on "Could not parse global class" in whichever correct script shares the bare name. Restart the editor with editor_launch restart, then run project_import refresh_classes.`);
43060
43133
  } else if (dropped.length > 0) {
43061
43134
  notes.push(`The scan wrote the class cache from the list this editor is holding, and that list still has ${dropped.join(", ")} for a script that is gone, so the cache was rebuilt from the files without it. The editor goes on holding it and writes it back on every scan until it is restarted with editor_launch restart; until then a scan is followed by this same rebuild.`);
43062
43135
  }
43063
43136
  return this.jsonTextResponse({
43064
- ok: !busy && unseen.length === 0 && stillGone.length === 0 && stillAtMissingPaths.length === 0 && (dependents === null || dependents.notReloaded.length === 0) && checked.unchecked === undefined,
43137
+ ok: !busy && unseen.length === 0 && stillGone.length === 0 && stillAtMissingPaths.length === 0 && (dependents === null || dependents.notReloaded.length === 0) && reloaded.problem === undefined && checked.unchecked === undefined,
43065
43138
  stillWorking: busy,
43066
43139
  waitedMs: Date.now() - started,
43067
43140
  waitedForEditorScanMs: waitedForEditorMs > 0 ? waitedForEditorMs : undefined,
@@ -43079,6 +43152,8 @@ class GodotServer {
43079
43152
  cacheDropped: dropped.length > 0 ? dropped : undefined,
43080
43153
  cacheAtMissingPaths: stillAtMissingPaths.length > 0 ? stillAtMissingPaths : undefined,
43081
43154
  classesUnchecked: checked.unchecked,
43155
+ uidsReread: uidsReread.length > 0 ? uidsReread : undefined,
43156
+ uidsDuplicatedOnDisk: uidsDuplicatedOnDisk.length > 0 ? uidsDuplicatedOnDisk : undefined,
43082
43157
  note: notes.length > 0 ? notes.join(" ") : undefined
43083
43158
  });
43084
43159
  }
@@ -43095,7 +43170,13 @@ class GodotServer {
43095
43170
  }
43096
43171
  try {
43097
43172
  const answer = asParams(await this.godotBridge.invokeTool("reload_script", { scriptPath }));
43098
- if (readArray(answer, "methods") === undefined) {
43173
+ const failed = answer["failed"];
43174
+ if (typeof failed === "number" && failed !== 0) {
43175
+ notReloaded.push({
43176
+ scriptPath,
43177
+ problem: `it did not compile: Godot answered error ${failed}${readString(answer, "failedAs") ? `, ${readString(answer, "failedAs")}` : ""}`
43178
+ });
43179
+ } else if (readArray(answer, "methods") === undefined) {
43099
43180
  notReloaded.push({
43100
43181
  scriptPath,
43101
43182
  problem: "the editor would not say what it has after reloading it"
@@ -74,16 +74,22 @@ func reload_script(args: Dictionary) -> Dictionary:
74
74
  var reader: FileAccess = FileAccess.open(path, FileAccess.READ)
75
75
  if reader == null:
76
76
  return {"ok": false, "error": "Could not read " + path + ": " + str(FileAccess.get_open_error())}
77
+ var previous: String = script.source_code
77
78
  script.source_code = reader.get_as_text()
78
79
  reader.close()
79
80
 
81
+ # A reload that fails is still answered as a call that ran, because the readings are what a
82
+ # caller needs then: a failure travels back as its message alone, and a project met one and
83
+ # could not tell whether the copy had been damaged by it or before it. The old text goes back
84
+ # into the object, so what it holds and the source it says it was built from still agree.
80
85
  var failed: Error = script.reload(true)
81
86
  if failed != OK:
82
- return {"ok": false, "error": "Reloading " + path + " answered error " + str(failed)}
83
-
87
+ script.source_code = previous
84
88
  return {
85
89
  "ok": true,
86
90
  "script": path,
91
+ "failed": failed,
92
+ "failedAs": error_string(failed) if failed != OK else "",
87
93
  "heldBefore": held,
88
94
  "methods": _method_names(script),
89
95
  "heldConstantsBefore": held_constants,
@@ -665,7 +665,9 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
665
665
  var busy: bool = filesystem.is_scanning() or filesystem.is_importing()
666
666
  var started: bool = false
667
667
  var completed_before: int = _scans_completed
668
+ var uids: Dictionary = {"reread": [], "duplicated": []}
668
669
  if not Read.as_bool(args.get("statusOnly", false)) and not busy:
670
+ uids = _reread_changed_uids(filesystem)
669
671
  filesystem.scan()
670
672
  # Whether the scan ran, read off the editor rather than assumed. `scan()` returns without
671
673
  # a word while the thread of the scan before is still to be joined, which is a frame or
@@ -687,9 +689,65 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
687
689
  "importing": filesystem.is_importing(),
688
690
  "scansCompletedBefore": completed_before,
689
691
  "scansCompleted": _scans_completed,
692
+ "uidsReread": uids["reread"],
693
+ "uidsDuplicatedOnDisk": uids["duplicated"],
690
694
  }
691
695
 
692
696
 
697
+ ## Has the editor read again every file whose `.uid` names a UID the editor does not hold for it.
698
+ ##
699
+ ## The scan re-reads a file when the file changes and not when only the `.uid` beside it does, so a
700
+ ## script copied with its `.uid` and then given a fresh one keeps the copied UID in the editor, and
701
+ ## every headless engine reading the editor's cache warns of a duplicate. Measured on 4.7.2: a scan
702
+ ## after the `.uid` was rewritten left both scripts on one UID in the cache. What the editor holds
703
+ ## is read off its UID table, which maps each UID to one path, so a `.uid` whose UID is missing from
704
+ ## the table or mapped to another file is one the editor has not taken in.
705
+ ##
706
+ ## Two files on disk naming one UID are left alone and named: reading them again cannot settle
707
+ ## which of them owns it, and doing it on every scan would only move the UID between them.
708
+ func _reread_changed_uids(filesystem: EditorFileSystem) -> Dictionary:
709
+ var sidecars: Array[String] = []
710
+ _uid_files("res://", sidecars)
711
+ var claimed: Dictionary = {}
712
+ for sidecar: String in sidecars:
713
+ var id: int = ResourceUID.text_to_id(FileAccess.get_file_as_string(sidecar).strip_edges())
714
+ var path: String = sidecar.trim_suffix(".uid")
715
+ if id == ResourceUID.INVALID_ID or not FileAccess.file_exists(path):
716
+ continue
717
+ var paths: Array = claimed.get_or_add(id, [])
718
+ paths.append(path)
719
+ var reread: Array[String] = []
720
+ var duplicated: Array[Array] = []
721
+ for id: int in claimed:
722
+ var paths: Array = claimed[id]
723
+ if paths.size() > 1:
724
+ paths.sort()
725
+ duplicated.append(paths)
726
+ continue
727
+ var path: String = str(paths[0])
728
+ if ResourceUID.has_id(id) and ResourceUID.get_id_path(id) == path:
729
+ continue
730
+ filesystem.update_file(path)
731
+ reread.append(path)
732
+ reread.sort()
733
+ duplicated.sort()
734
+ return {"reread": reread, "duplicated": duplicated}
735
+
736
+
737
+ ## Every `.uid` under [param directory], skipping what the editor skips: hidden directories and any
738
+ ## holding a `.gdignore`.
739
+ func _uid_files(directory: String, found: Array[String]) -> void:
740
+ var listing: DirAccess = DirAccess.open(directory)
741
+ if listing == null or FileAccess.file_exists(directory.path_join(".gdignore")):
742
+ return
743
+ for child: String in listing.get_directories():
744
+ if not child.begins_with("."):
745
+ _uid_files(directory.path_join(child), found)
746
+ for file: String in listing.get_files():
747
+ if file.ends_with(".uid"):
748
+ found.append(directory.path_join(file))
749
+
750
+
693
751
  ## Whether the editor is scanning, and how long ago its last scan finished, without asking for one.
694
752
  ##
695
753
  ## For a caller about to start a game: the editor rewrites the class cache a frame after a scan
@@ -640,9 +640,12 @@ func set_property(params: Dictionary) -> Dictionary:
640
640
  # the answer then showed the old value as the new one, shaped as a success: a typed container
641
641
  # refusing a plain list did exactly that. A value the property changed on the way in, a setter
642
642
  # clamping it, still changed it, so only a write that left the property as it was is refused.
643
+ # And only one that asked for a change: the value the property already held, written again, left
644
+ # it as it was because nothing was asked of it.
645
+ var already: bool = _held_already(given, old_value)
643
646
  if (
644
647
  Values.comparable(given, old_value)
645
- and given != old_value
648
+ and not already
646
649
  and Values.comparable(now, old_value)
647
650
  and now == old_value
648
651
  ):
@@ -663,7 +666,7 @@ func set_property(params: Dictionary) -> Dictionary:
663
666
  )
664
667
  }
665
668
 
666
- return {
669
+ var answer: Dictionary = {
667
670
  "type": "property_set",
668
671
  "path": node_path,
669
672
  "property": property,
@@ -671,6 +674,27 @@ func set_property(params: Dictionary) -> Dictionary:
671
674
  "new_value": _values.serialize(now),
672
675
  "elapsed_usec": elapsed
673
676
  }
677
+ if already and Values.comparable(now, old_value) and now == old_value:
678
+ answer["unchanged"] = true
679
+ return answer
680
+
681
+
682
+ ## Whether [param given] is the value [param held] already is, at the precision the property keeps.
683
+ ##
684
+ ## A float arrives as 64 bits and most engine properties keep 32: Camera3D.h_offset given -1.1
685
+ ## reads back as -1.10000002384186, so comparing the two exactly took a write of the value already
686
+ ## held for one the engine refused (#780). Rounding both to 32 bits is exact for a 32-bit property.
687
+ ## A 64-bit one can take a write this calls the same, and then reads back changed, which the caller
688
+ ## checks before calling the write unchanged. Vectors and colours hold 32-bit parts in the value
689
+ ## itself, so they already compare at the property's precision.
690
+ static func _held_already(given: Variant, held: Variant) -> bool:
691
+ if not Values.comparable(given, held):
692
+ return false
693
+ if given == held:
694
+ return true
695
+ if typeof(given) != TYPE_FLOAT or typeof(held) != TYPE_FLOAT:
696
+ return false
697
+ return PackedFloat32Array([given])[0] == PackedFloat32Array([held])[0]
674
698
 
675
699
 
676
700
  func call_method(params: Dictionary) -> Dictionary:
@@ -140,7 +140,23 @@ func wait_until(params: Dictionary) -> Dictionary:
140
140
 
141
141
  var current: Variant = watched["value"]
142
142
  var wanted: Variant = _values.fitted(params["value"], typeof(current))
143
+ # Read against what the property is declared as, not only against what it holds this frame: an
144
+ # object property holding null holds nothing typed, so any value was taken as it came and compared
145
+ # with null until the time ran out. #772 waited a minute for "null" that way and answered met
146
+ # false with value null beside it.
147
+ var declared: int = watched["declared"]
148
+ if declared == TYPE_OBJECT and wanted is String and str(wanted).strip_edges() == "null":
149
+ wanted = null
143
150
  var refused: String = _not_comparable(current, wanted, node_path, property)
151
+ var never_held: bool = typeof(wanted) != TYPE_NIL and typeof(wanted) != TYPE_OBJECT
152
+ if refused.is_empty() and declared == TYPE_OBJECT and never_held:
153
+ refused = (
154
+ (
155
+ "%s.%s is declared to hold an object and the value to wait for is %s, which it can"
156
+ + " never hold: wait for null, or for the object's own value on a property of it."
157
+ )
158
+ % [node_path, property, type_string(typeof(wanted))]
159
+ )
144
160
  if not refused.is_empty():
145
161
  return {"type": "error", "message": refused}
146
162
 
@@ -224,7 +240,19 @@ static func _watched(node: Node, node_path: String, property: String) -> Diction
224
240
  var missing: String = Paths.nothing_under(holder, named, str(reached["called"]))
225
241
  if not missing.is_empty():
226
242
  return {"type": "error", "message": missing}
227
- return {"value": Paths.read_under(holder, named)}
243
+ return {"value": Paths.read_under(holder, named), "declared": _declared_type(holder, named)}
244
+
245
+
246
+ ## The type [param named] is declared with on [param holder], or TYPE_NIL for anything that declares
247
+ ## none: an untyped property, a step into a list or map, or a call.
248
+ static func _declared_type(holder: Variant, named: String) -> int:
249
+ if typeof(holder) != TYPE_OBJECT or not Paths.method_of(named).is_empty():
250
+ return TYPE_NIL
251
+ var object: Object = holder
252
+ for entry: Dictionary in object.get_property_list():
253
+ if str(entry.get("name", "")) == named:
254
+ return Read.as_int(entry.get("type", TYPE_NIL), TYPE_NIL)
255
+ return TYPE_NIL
228
256
 
229
257
 
230
258
  ## Waits until something under [param node_path] has [param said] written on it.
package/build/index.js CHANGED
@@ -26276,6 +26276,18 @@ function classRemedy(entries) {
26276
26276
  const reload = declaring.length === 1 ? `editor_rescan with reloadScript set to ${declaring[0]}` : `editor_rescan with reloadScript, a call each for ${declaring.join(" and ")} since it takes one script,`;
26277
26277
  return `For ${entries.map(written).join(", ")}, run ${reload} first. In the one project that has ` + "reproduced a stale enum member, twice, the reload cleared it at once both times, and the one " + "plain editor_rescan tried left the diagnostic standing.";
26278
26278
  }
26279
+ function failedReloadNote(failed) {
26280
+ const missing = (before, after) => before.filter((name) => !after.includes(name));
26281
+ const lost = [
26282
+ ...missing(failed.heldBefore, failed.heldAfter),
26283
+ ...missing(failed.constantsBefore, failed.constantsAfter)
26284
+ ];
26285
+ const unchanged = lost.length === 0 && missing(failed.heldAfter, failed.heldBefore).length === 0 && missing(failed.constantsAfter, failed.constantsBefore).length === 0;
26286
+ const answered = failed.codeName === "" ? `error ${failed.code}` : `error ${failed.code}, ${failed.codeName}`;
26287
+ const copy = unchanged ? "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.`;
26288
+ 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.`;
26289
+ return `${failed.script} did not compile, so nothing was reloaded: Godot answered ${answered}. ${copy} ` + `script_diagnostics on it gives the errors.${order}`;
26290
+ }
26279
26291
  function uncachedClassNote(types) {
26280
26292
  if (types.length === 0) {
26281
26293
  return "";
@@ -28280,7 +28292,28 @@ function reason(stdout, stderr) {
28280
28292
  }
28281
28293
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
28282
28294
  }
28283
- async function runImport(godotPath, projectPath) {
28295
+ function librariesNotCopied(messages) {
28296
+ const found = new Set;
28297
+ for (const entry of messages) {
28298
+ const library = /Error copying library: (.+)$/.exec(entry.text)?.[1]?.trim();
28299
+ if (library !== undefined && library !== "") {
28300
+ found.add(library);
28301
+ }
28302
+ }
28303
+ return [...found];
28304
+ }
28305
+ function extensionRetryNote(libraries) {
28306
+ return `The first import could not load ${libraries.join(" and ")}: on Windows an engine running as the editor ` + "loads a GDExtension from a ~ copy beside the library, and the editor open on this project holds its own " + "copy there, so the first engine started beside it cannot make one. That failed attempt moves the " + "editor's copy aside, so the import was run again and loaded the extension, and the messages are from " + "that second run. The editor's copy is left beside the library as a ~RF….TMP file, which can be deleted " + "once the editor has exited.";
28307
+ }
28308
+ async function runImport(godotPath, projectPath, once = importOnce) {
28309
+ const first = await once(godotPath, projectPath);
28310
+ const collided = librariesNotCopied(first.messages);
28311
+ if (collided.length === 0) {
28312
+ return first;
28313
+ }
28314
+ return { ...await once(godotPath, projectPath), librariesRetried: collided };
28315
+ }
28316
+ async function importOnce(godotPath, projectPath) {
28284
28317
  const logDir = scratchDirectory("import");
28285
28318
  try {
28286
28319
  const { stderr } = await run3(godotPath, ["--headless", "--log-file", join12(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
@@ -30988,7 +31021,7 @@ var TOOL_SPECS = [
30988
31021
  },
30989
31022
  {
30990
31023
  name: "editor_rescan",
30991
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. A scan or an import the editor is already running is waited out first rather than scanned over, which would start a second import over it, and the answer says how long under waitedForEditorScanMs. 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 this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A class the scan brings in is named under broughtIn, and every script naming it is reloaded in the editor and named under dependentsReloaded: what the editor compiled from them while it could not see the class stays compiled that way after the scan, and a script extending a new base class went on reporting \"Could not find base class\" until reloaded. One that would not reload is named under dependentsNotReloaded and the call is not ok. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. The other direction is guarded the same way: a class the editor still holds for a script that was renamed or deleted comes back into the cache at a path that is not there, and the next engine to read it fails on \"Could not parse global class\" in whichever correct script shares the bare name, so the cache is rebuilt without it and the answer names it under cacheDropped; the editor goes on holding it until it is restarted, and every scan until then ends in that rebuild. A script or shader written outside the editor has no .uid sidecar until something imports it, and the scan writes one beside it, which a project that commits sidecars needs before the commit. Needs the editor connected.",
31024
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. A scan or an import the editor is already running is waited out first rather than scanned over, which would start a second import over it, and the answer says how long under waitedForEditorScanMs. 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 this call on its own, with no change to the declaring script: measured against a real editor on three platforms, with idle waits of the same length ruled out so the scan is credited rather than the time it takes, and reproduced in a second project against its own reproduction in 203ms. editor_launch restart also does it and costs a window, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. The walk also passes over a file whose .uid alone changed, so the editor keeps the old UID and every engine reading its cache warns of a duplicate; before scanning, each file whose .uid names a UID the editor does not hold for it is read again and named under uidsReread, and files on disk naming one UID between them are named under uidsDuplicatedOnDisk, which reading again cannot settle. A class the scan brings in is named under broughtIn, and every script naming it is reloaded in the editor and named under dependentsReloaded: what the editor compiled from them while it could not see the class stays compiled that way after the scan, and a script extending a new base class went on reporting \"Could not find base class\" until reloaded. One that would not reload is named under dependentsNotReloaded and the call is not ok, as it is not when the script named in reloadScript was not reloaded. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve goes out of that file with it. That is not a reason to refuse the scan, because on a class the editor has merely not walked yet the scan is the cure; what the answer does instead is name the loss under cacheLost and rebuild the cache from the files, naming what came back under cacheRestored, so no fresh engine, CI run or clone inherits the short file. An editor that lost classes is still holding the short list, so the note says to restart it before scanning again. The other direction is guarded the same way: a class the editor still holds for a script that was renamed or deleted comes back into the cache at a path that is not there, and the next engine to read it fails on \"Could not parse global class\" in whichever correct script shares the bare name, so the cache is rebuilt without it and the answer names it under cacheDropped; the editor goes on holding it until it is restarted, and every scan until then ends in that rebuild. A script or shader written outside the editor has no .uid sidecar until something imports it, and the scan writes one beside it, which a project that commits sidecars needs before the commit. Needs the editor connected.",
30992
31025
  parameters: {
30993
31026
  projectPath: PROJECT_PATH,
30994
31027
  timeoutMs: {
@@ -30998,7 +31031,7 @@ var TOOL_SPECS = [
30998
31031
  },
30999
31032
  reloadScript: {
31000
31033
  type: "string",
31001
- 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."
31034
+ 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."
31002
31035
  }
31003
31036
  },
31004
31037
  requires: ["projectPath"]
@@ -31156,7 +31189,7 @@ var TOOL_SPECS = [
31156
31189
  property: {
31157
31190
  type: "string",
31158
31191
  ops: ["set"],
31159
- 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.'
31192
+ 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.'
31160
31193
  },
31161
31194
  value: {
31162
31195
  ops: ["set"],
@@ -31376,7 +31409,10 @@ var TOOL_SPECS = [
31376
31409
  ops: ["until"],
31377
31410
  description: 'until: the property name, or a colon path through what the node holds, "_game:run:day", read the way runtime_inspect property reads one, a step written as a call included. Walked again on every frame, so a holder the game replaces while the wait is on is followed.'
31378
31411
  },
31379
- value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
31412
+ value: {
31413
+ ops: ["until"],
31414
+ description: "until: the value to wait for, fitted to the property's type. On a property declared to hold an object, null, which the text null also means; any other value is refused, since the property can never hold it."
31415
+ },
31380
31416
  says: {
31381
31417
  type: "string",
31382
31418
  ops: ["until"],
@@ -33073,7 +33109,8 @@ class GodotServer {
33073
33109
  payload: {
33074
33110
  uidsCreated: given,
33075
33111
  stillWithoutUid: after,
33076
- note: uidsLeftNote(after.length)
33112
+ note: uidsLeftNote(after.length),
33113
+ ...imported.librariesRetried === undefined ? {} : { extensionNote: extensionRetryNote(imported.librariesRetried) }
33077
33114
  }
33078
33115
  }, scanned));
33079
33116
  }
@@ -33229,6 +33266,9 @@ class GodotServer {
33229
33266
  if (imported.messages.length > 0) {
33230
33267
  extra["engine_messages"] = imported.messages;
33231
33268
  }
33269
+ if (imported.librariesRetried !== undefined) {
33270
+ extra["extensionNote"] = extensionRetryNote(imported.librariesRetried);
33271
+ }
33232
33272
  }
33233
33273
  const after = await this.operation("get_import_status", resourcePath === undefined ? { includeUpToDate: true } : { resourcePath }, projectPath);
33234
33274
  if (!after.ok) {
@@ -33757,6 +33797,11 @@ class GodotServer {
33757
33797
  shortListEditorPid: this.godotBridge.getStatus().editorPid ?? null
33758
33798
  });
33759
33799
  }
33800
+ classesAScriptNames(projectPath, scriptPath) {
33801
+ const source = this.sourceOfClass(projectPath, scriptPath);
33802
+ const known = cachedClasses(projectPath) ?? declaredClasses(projectPath);
33803
+ return source === null ? [] : classesNamedIn(source, known).filter((name) => known.get(name) !== scriptPath);
33804
+ }
33760
33805
  sourceOfClass(projectPath, resourcePath) {
33761
33806
  const contained = resolveWithinProject(projectPath, resourcePath);
33762
33807
  if (!contained.ok || !existsSync12(contained.absolutePath)) {
@@ -35422,6 +35467,8 @@ class GodotServer {
35422
35467
  let waitedForEditorMs = 0;
35423
35468
  let first;
35424
35469
  let completedBefore;
35470
+ let uidsReread = [];
35471
+ let uidsDuplicatedOnDisk = [];
35425
35472
  for (;; ) {
35426
35473
  const busySince = Date.now();
35427
35474
  let wasBusy = false;
@@ -35438,6 +35485,11 @@ class GodotServer {
35438
35485
  }
35439
35486
  const firstAnswer = asParams(JSON.parse(first.content[0]?.text ?? "{}"));
35440
35487
  completedBefore = readNumber(firstAnswer, "scansCompletedBefore");
35488
+ uidsReread = [...uidsReread, ...(readArray(firstAnswer, "uidsReread") ?? []).map(String)];
35489
+ const duplicated = readArray(firstAnswer, "uidsDuplicatedOnDisk");
35490
+ if (duplicated !== undefined) {
35491
+ uidsDuplicatedOnDisk = duplicated.map((group) => Array.isArray(group) ? group.map(String) : []);
35492
+ }
35441
35493
  if (firstAnswer["started"] !== false || Date.now() - started >= timeoutMs) {
35442
35494
  break;
35443
35495
  }
@@ -35487,12 +35539,30 @@ class GodotServer {
35487
35539
  const found = readArray(answer, field);
35488
35540
  return found ? { [field]: found.map(String) } : {};
35489
35541
  };
35490
- reloaded = methods ? {
35491
- methods: methods.map(String),
35492
- ...listed("heldBefore"),
35493
- ...listed("constants"),
35494
- ...listed("heldConstantsBefore")
35495
- } : { problem: `the editor would not say what ${reloading} has after reloading it` };
35542
+ const failed = answer["failed"];
35543
+ if (typeof failed === "number" && failed !== 0) {
35544
+ reloaded = {
35545
+ ...listed("heldBefore"),
35546
+ ...listed("heldConstantsBefore"),
35547
+ problem: failedReloadNote({
35548
+ script: reloading,
35549
+ code: failed,
35550
+ codeName: readString(answer, "failedAs") ?? "",
35551
+ heldBefore: listed("heldBefore")["heldBefore"] ?? [],
35552
+ heldAfter: methods?.map(String) ?? [],
35553
+ constantsBefore: listed("heldConstantsBefore")["heldConstantsBefore"] ?? [],
35554
+ constantsAfter: listed("constants")["constants"] ?? [],
35555
+ names: projectPath === "" ? [] : this.classesAScriptNames(projectPath, reloading)
35556
+ })
35557
+ };
35558
+ } else {
35559
+ reloaded = methods ? {
35560
+ methods: methods.map(String),
35561
+ ...listed("heldBefore"),
35562
+ ...listed("constants"),
35563
+ ...listed("heldConstantsBefore")
35564
+ } : { problem: `the editor would not say what ${reloading} has after reloading it` };
35565
+ }
35496
35566
  } catch (error) {
35497
35567
  reloaded = { problem: `${reloading} could not be reloaded: ${errorMessage(error)}` };
35498
35568
  }
@@ -35515,13 +35585,16 @@ class GodotServer {
35515
35585
  if (dependents !== null && dependents.notReloaded.length > 0) {
35516
35586
  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.`);
35517
35587
  }
35588
+ for (const group of uidsDuplicatedOnDisk) {
35589
+ 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.`);
35590
+ }
35518
35591
  if (stillAtMissingPaths.length > 0) {
35519
35592
  notes.push(`The scan wrote the class cache with ${stillAtMissingPaths.join(", ")} at a path that is not on disk, and rebuilding the cache from the files did not take it out. The next engine to read the cache fails on "Could not parse global class" in whichever correct script shares the bare name. Restart the editor with editor_launch restart, then run project_import refresh_classes.`);
35520
35593
  } else if (dropped.length > 0) {
35521
35594
  notes.push(`The scan wrote the class cache from the list this editor is holding, and that list still has ${dropped.join(", ")} for a script that is gone, so the cache was rebuilt from the files without it. The editor goes on holding it and writes it back on every scan until it is restarted with editor_launch restart; until then a scan is followed by this same rebuild.`);
35522
35595
  }
35523
35596
  return this.jsonTextResponse({
35524
- ok: !busy && unseen.length === 0 && stillGone.length === 0 && stillAtMissingPaths.length === 0 && (dependents === null || dependents.notReloaded.length === 0) && checked.unchecked === undefined,
35597
+ ok: !busy && unseen.length === 0 && stillGone.length === 0 && stillAtMissingPaths.length === 0 && (dependents === null || dependents.notReloaded.length === 0) && reloaded.problem === undefined && checked.unchecked === undefined,
35525
35598
  stillWorking: busy,
35526
35599
  waitedMs: Date.now() - started,
35527
35600
  waitedForEditorScanMs: waitedForEditorMs > 0 ? waitedForEditorMs : undefined,
@@ -35539,6 +35612,8 @@ class GodotServer {
35539
35612
  cacheDropped: dropped.length > 0 ? dropped : undefined,
35540
35613
  cacheAtMissingPaths: stillAtMissingPaths.length > 0 ? stillAtMissingPaths : undefined,
35541
35614
  classesUnchecked: checked.unchecked,
35615
+ uidsReread: uidsReread.length > 0 ? uidsReread : undefined,
35616
+ uidsDuplicatedOnDisk: uidsDuplicatedOnDisk.length > 0 ? uidsDuplicatedOnDisk : undefined,
35542
35617
  note: notes.length > 0 ? notes.join(" ") : undefined
35543
35618
  });
35544
35619
  }
@@ -35555,7 +35630,13 @@ class GodotServer {
35555
35630
  }
35556
35631
  try {
35557
35632
  const answer = asParams(await this.godotBridge.invokeTool("reload_script", { scriptPath }));
35558
- if (readArray(answer, "methods") === undefined) {
35633
+ const failed = answer["failed"];
35634
+ if (typeof failed === "number" && failed !== 0) {
35635
+ notReloaded.push({
35636
+ scriptPath,
35637
+ problem: `it did not compile: Godot answered error ${failed}${readString(answer, "failedAs") ? `, ${readString(answer, "failedAs")}` : ""}`
35638
+ });
35639
+ } else if (readArray(answer, "methods") === undefined) {
35559
35640
  notReloaded.push({
35560
35641
  scriptPath,
35561
35642
  problem: "the editor would not say what it has after reloading it"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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",