gdharness 1.1.17 → 1.1.19

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() });
@@ -17343,7 +17364,7 @@ var init_tool_definitions = __esm(() => {
17343
17364
  },
17344
17365
  {
17345
17366
  name: "editor_rescan",
17346
- 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.",
17347
17368
  parameters: {
17348
17369
  projectPath: PROJECT_PATH,
17349
17370
  timeoutMs: {
@@ -17511,12 +17532,12 @@ var init_tool_definitions = __esm(() => {
17511
17532
  property: {
17512
17533
  type: "string",
17513
17534
  ops: ["set"],
17514
- 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.'
17515
17536
  },
17516
17537
  value: {
17517
17538
  ops: ["set"],
17518
17539
  blank: true,
17519
- description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
17540
+ description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
17520
17541
  },
17521
17542
  method: {
17522
17543
  type: "string",
@@ -17731,7 +17752,10 @@ var init_tool_definitions = __esm(() => {
17731
17752
  ops: ["until"],
17732
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.'
17733
17754
  },
17734
- 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
+ },
17735
17759
  says: {
17736
17760
  type: "string",
17737
17761
  ops: ["until"],
@@ -40625,7 +40649,8 @@ class GodotServer {
40625
40649
  payload: {
40626
40650
  uidsCreated: given,
40627
40651
  stillWithoutUid: after,
40628
- note: uidsLeftNote(after.length)
40652
+ note: uidsLeftNote(after.length),
40653
+ ...imported.librariesRetried === undefined ? {} : { extensionNote: extensionRetryNote(imported.librariesRetried) }
40629
40654
  }
40630
40655
  }, scanned));
40631
40656
  }
@@ -40781,6 +40806,9 @@ class GodotServer {
40781
40806
  if (imported.messages.length > 0) {
40782
40807
  extra["engine_messages"] = imported.messages;
40783
40808
  }
40809
+ if (imported.librariesRetried !== undefined) {
40810
+ extra["extensionNote"] = extensionRetryNote(imported.librariesRetried);
40811
+ }
40784
40812
  }
40785
40813
  const after = await this.operation("get_import_status", resourcePath === undefined ? { includeUpToDate: true } : { resourcePath }, projectPath);
40786
40814
  if (!after.ok) {
@@ -42979,6 +43007,8 @@ class GodotServer {
42979
43007
  let waitedForEditorMs = 0;
42980
43008
  let first;
42981
43009
  let completedBefore;
43010
+ let uidsReread = [];
43011
+ let uidsDuplicatedOnDisk = [];
42982
43012
  for (;; ) {
42983
43013
  const busySince = Date.now();
42984
43014
  let wasBusy = false;
@@ -42995,6 +43025,11 @@ class GodotServer {
42995
43025
  }
42996
43026
  const firstAnswer = asParams(JSON.parse(first.content[0]?.text ?? "{}"));
42997
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
+ }
42998
43033
  if (firstAnswer["started"] !== false || Date.now() - started >= timeoutMs) {
42999
43034
  break;
43000
43035
  }
@@ -43090,13 +43125,16 @@ class GodotServer {
43090
43125
  if (dependents !== null && dependents.notReloaded.length > 0) {
43091
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.`);
43092
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
+ }
43093
43131
  if (stillAtMissingPaths.length > 0) {
43094
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.`);
43095
43133
  } else if (dropped.length > 0) {
43096
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.`);
43097
43135
  }
43098
43136
  return this.jsonTextResponse({
43099
- 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,
43100
43138
  stillWorking: busy,
43101
43139
  waitedMs: Date.now() - started,
43102
43140
  waitedForEditorScanMs: waitedForEditorMs > 0 ? waitedForEditorMs : undefined,
@@ -43114,6 +43152,8 @@ class GodotServer {
43114
43152
  cacheDropped: dropped.length > 0 ? dropped : undefined,
43115
43153
  cacheAtMissingPaths: stillAtMissingPaths.length > 0 ? stillAtMissingPaths : undefined,
43116
43154
  classesUnchecked: checked.unchecked,
43155
+ uidsReread: uidsReread.length > 0 ? uidsReread : undefined,
43156
+ uidsDuplicatedOnDisk: uidsDuplicatedOnDisk.length > 0 ? uidsDuplicatedOnDisk : undefined,
43117
43157
  note: notes.length > 0 ? notes.join(" ") : undefined
43118
43158
  });
43119
43159
  }
@@ -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:
@@ -819,7 +843,12 @@ static func _sort_of(container: Variant) -> String:
819
843
  ## call or the write was made on, and handed over as that instance rather than a copy: a method
820
844
  ## given a piece of the game's state acts on the piece the game keeps, and a slot written with one
821
845
  ## holds the game's own.
846
+ ##
847
+ ## The text null names no object, which is what an object slot or parameter takes to be emptied. A
848
+ ## caller's null arrived as that text in #772 and #783, and was looked up as a node called "null".
822
849
  func _object_named(node: Node, node_path: String, given: Variant, declared: String) -> Dictionary:
850
+ if given is String and str(given).strip_edges() == "null":
851
+ return {"object": null}
823
852
  var found: Dictionary = Paths.object_at(_host.get_tree().root, node, node_path, given)
824
853
  if found.has("message"):
825
854
  return found
@@ -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
@@ -28292,7 +28292,28 @@ function reason(stdout, stderr) {
28292
28292
  }
28293
28293
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
28294
28294
  }
28295
- 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) {
28296
28317
  const logDir = scratchDirectory("import");
28297
28318
  try {
28298
28319
  const { stderr } = await run3(godotPath, ["--headless", "--log-file", join12(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
@@ -31000,7 +31021,7 @@ var TOOL_SPECS = [
31000
31021
  },
31001
31022
  {
31002
31023
  name: "editor_rescan",
31003
- 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.",
31004
31025
  parameters: {
31005
31026
  projectPath: PROJECT_PATH,
31006
31027
  timeoutMs: {
@@ -31168,12 +31189,12 @@ var TOOL_SPECS = [
31168
31189
  property: {
31169
31190
  type: "string",
31170
31191
  ops: ["set"],
31171
- 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.'
31172
31193
  },
31173
31194
  value: {
31174
31195
  ops: ["set"],
31175
31196
  blank: true,
31176
- description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
31197
+ description: `set: the value, fitted to the property's type. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key.`
31177
31198
  },
31178
31199
  method: {
31179
31200
  type: "string",
@@ -31388,7 +31409,10 @@ var TOOL_SPECS = [
31388
31409
  ops: ["until"],
31389
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.'
31390
31411
  },
31391
- 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
+ },
31392
31416
  says: {
31393
31417
  type: "string",
31394
31418
  ops: ["until"],
@@ -33085,7 +33109,8 @@ class GodotServer {
33085
33109
  payload: {
33086
33110
  uidsCreated: given,
33087
33111
  stillWithoutUid: after,
33088
- note: uidsLeftNote(after.length)
33112
+ note: uidsLeftNote(after.length),
33113
+ ...imported.librariesRetried === undefined ? {} : { extensionNote: extensionRetryNote(imported.librariesRetried) }
33089
33114
  }
33090
33115
  }, scanned));
33091
33116
  }
@@ -33241,6 +33266,9 @@ class GodotServer {
33241
33266
  if (imported.messages.length > 0) {
33242
33267
  extra["engine_messages"] = imported.messages;
33243
33268
  }
33269
+ if (imported.librariesRetried !== undefined) {
33270
+ extra["extensionNote"] = extensionRetryNote(imported.librariesRetried);
33271
+ }
33244
33272
  }
33245
33273
  const after = await this.operation("get_import_status", resourcePath === undefined ? { includeUpToDate: true } : { resourcePath }, projectPath);
33246
33274
  if (!after.ok) {
@@ -35439,6 +35467,8 @@ class GodotServer {
35439
35467
  let waitedForEditorMs = 0;
35440
35468
  let first;
35441
35469
  let completedBefore;
35470
+ let uidsReread = [];
35471
+ let uidsDuplicatedOnDisk = [];
35442
35472
  for (;; ) {
35443
35473
  const busySince = Date.now();
35444
35474
  let wasBusy = false;
@@ -35455,6 +35485,11 @@ class GodotServer {
35455
35485
  }
35456
35486
  const firstAnswer = asParams(JSON.parse(first.content[0]?.text ?? "{}"));
35457
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
+ }
35458
35493
  if (firstAnswer["started"] !== false || Date.now() - started >= timeoutMs) {
35459
35494
  break;
35460
35495
  }
@@ -35550,13 +35585,16 @@ class GodotServer {
35550
35585
  if (dependents !== null && dependents.notReloaded.length > 0) {
35551
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.`);
35552
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
+ }
35553
35591
  if (stillAtMissingPaths.length > 0) {
35554
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.`);
35555
35593
  } else if (dropped.length > 0) {
35556
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.`);
35557
35595
  }
35558
35596
  return this.jsonTextResponse({
35559
- 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,
35560
35598
  stillWorking: busy,
35561
35599
  waitedMs: Date.now() - started,
35562
35600
  waitedForEditorScanMs: waitedForEditorMs > 0 ? waitedForEditorMs : undefined,
@@ -35574,6 +35612,8 @@ class GodotServer {
35574
35612
  cacheDropped: dropped.length > 0 ? dropped : undefined,
35575
35613
  cacheAtMissingPaths: stillAtMissingPaths.length > 0 ? stillAtMissingPaths : undefined,
35576
35614
  classesUnchecked: checked.unchecked,
35615
+ uidsReread: uidsReread.length > 0 ? uidsReread : undefined,
35616
+ uidsDuplicatedOnDisk: uidsDuplicatedOnDisk.length > 0 ? uidsDuplicatedOnDisk : undefined,
35577
35617
  note: notes.length > 0 ? notes.join(" ") : undefined
35578
35618
  });
35579
35619
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
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",