gdharness 1.1.17 → 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
|
-
|
|
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,7 +17532,7 @@ 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"],
|
|
@@ -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: {
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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,7 +31189,7 @@ 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"],
|
|
@@ -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: {
|
|
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.
|
|
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",
|