gdharness 0.13.5 → 0.13.6

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
@@ -15741,7 +15741,7 @@ var init_tool_definitions = __esm(() => {
15741
15741
  },
15742
15742
  {
15743
15743
  name: "editor_run",
15744
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for.",
15744
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
15745
15745
  parameters: {
15746
15746
  projectPath: PROJECT_PATH,
15747
15747
  scene: {
@@ -15817,7 +15817,7 @@ var init_tool_definitions = __esm(() => {
15817
15817
  },
15818
15818
  {
15819
15819
  name: "editor_rescan",
15820
- description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure measured downstream is a change to the declaring script, saved, and then a rescan: the change on its own left every affected file reporting the same errors, and the rescan after it cleared them. Whether a rescan alone would have done it there is not known, because the rescans in that report may have answered before the scan ran, which is the timing this tool no longer has. editor_launch restart also does it, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. A scan writes .godot/global_script_class_cache.cfg from the list the editor is holding, so a class the editor cannot resolve can go out of that file with it, which one project saw and two could not reproduce; where it happens the answer names what the file lost under cacheLost, before a fresh engine, a CI run or a clone inherits it. Needs the editor connected.",
15820
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure measured downstream is a change to the declaring script, saved, and then a rescan: the change on its own left every affected file reporting the same errors, and the rescan after it cleared them. Whether a rescan alone would have done it there is not known, because the rescans in that report may have answered before the scan ran, which is the timing this tool no longer has. editor_launch restart also does it, and project_import refresh_classes does not: it rewrites the cache and does not touch what the editor is holding. 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. Needs the editor connected.",
15821
15821
  parameters: {
15822
15822
  projectPath: PROJECT_PATH,
15823
15823
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -16109,7 +16109,7 @@ var init_tool_definitions = __esm(() => {
16109
16109
  says: {
16110
16110
  type: "string",
16111
16111
  ops: ["until"],
16112
- description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, hidden nodes included."
16112
+ description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, hidden nodes included. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
16113
16113
  },
16114
16114
  timeoutMs: {
16115
16115
  type: "number",
@@ -38097,6 +38097,7 @@ class GodotServer {
38097
38097
  through: "gdharness",
38098
38098
  pid: started.process.pid ?? null,
38099
38099
  arguments: cmdArgs,
38100
+ transcript: started.transcript,
38100
38101
  refreshedClasses: refreshed.value,
38101
38102
  endedPreviousRun: ended ?? undefined,
38102
38103
  runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs),
@@ -38498,7 +38499,7 @@ class GodotServer {
38498
38499
  const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
38499
38500
  const before = projectPath === "" ? null : cachedClasses(projectPath);
38500
38501
  const writtenBefore = projectPath === "" ? null : cacheWrittenAt(projectPath);
38501
- const blind = projectPath === "" ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
38502
+ const blind = await this.classesTheEditorCannotSee(args);
38502
38503
  const atRisk = before === null ? [] : blind.unseen.filter((one) => before.has(one.className));
38503
38504
  const first = await this.handleViaBridge("rescan_filesystem", args);
38504
38505
  if (first.isError) {
@@ -38511,7 +38512,7 @@ class GodotServer {
38511
38512
  const pending = Boolean(status["pending"]) && Date.now() - started < SCAN_START_MS;
38512
38513
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]) || pending;
38513
38514
  }
38514
- if (!busy && atRisk.length > 0) {
38515
+ if (!busy && projectPath !== "" && (atRisk.length > 0 || blind.unchecked !== undefined)) {
38515
38516
  const until = Date.now() + CACHE_WRITE_MS;
38516
38517
  while (Date.now() < until && cacheWrittenAt(projectPath) === writtenBefore) {
38517
38518
  await new Promise((settle) => setTimeout(settle, 100));
@@ -38519,18 +38520,33 @@ class GodotServer {
38519
38520
  }
38520
38521
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
38521
38522
  const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
38523
+ const restored = lost.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
38522
38524
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
38523
38525
  const unseen = checked.unseen;
38526
+ const stillGone = lost.filter((name) => !restored.includes(name));
38524
38527
  return this.jsonTextResponse({
38525
- ok: !busy && unseen.length === 0 && lost.length === 0 && checked.unchecked === undefined,
38528
+ ok: !busy && unseen.length === 0 && stillGone.length === 0 && checked.unchecked === undefined,
38526
38529
  stillWorking: busy,
38527
38530
  waitedMs: Date.now() - started,
38528
38531
  unseenByEditor: unseen.length > 0 ? unseen : undefined,
38529
38532
  cacheLost: lost.length > 0 ? lost : undefined,
38533
+ cacheRestored: restored.length > 0 ? restored : undefined,
38530
38534
  classesUnchecked: checked.unchecked,
38531
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : lost.length > 0 ? "The scan wrote the class cache from the list this editor is holding, and that list is shorter than the file was: these classes were in it before the scan and are not now. Every engine that reads the cache next, including a test run, will report them as unknown identifiers in files nobody touched. Run project_import refresh_classes to put them back, and do not rescan again until the editor has been restarted with editor_launch restart, because it will write the same short list over it." : unseen.length > 0 ? "The scan finished and these classes are still not in the list the editor resolves against, so every use of them reads as an unknown identifier. Its walk skips a file another engine has already imported. Change the declaring script and rescan, or restart the editor with editor_launch restart." : undefined
38535
+ note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : stillGone.length > 0 ? "The scan wrote the class cache from the list this editor is holding, that list is shorter than the file was, and rebuilding the cache from the files did not bring these back. Every engine that reads the cache next, including a test run, will report them as unknown identifiers. Restart the editor with editor_launch restart before scanning again." : lost.length > 0 ? "The scan wrote the class cache from the list this editor is holding, which is shorter than the file was, so the cache was rebuilt from the files and these classes are back in it. Nothing was lost, but this editor is still holding the short list: do not rescan again until it has been restarted with editor_launch restart, because it will write the same list over the file each time." : unseen.length > 0 ? "The scan finished and these classes are still not in the list the editor resolves against, so every use of them reads as an unknown identifier. Its walk skips a file another engine has already imported. Change the declaring script and rescan, or restart the editor with editor_launch restart." : undefined
38532
38536
  });
38533
38537
  }
38538
+ async rebuildCacheAfterLoss(projectPath, lost) {
38539
+ if (projectPath === "") {
38540
+ return [];
38541
+ }
38542
+ const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
38543
+ if (!rebuilt.ok) {
38544
+ this.logDebug(`Could not rebuild the class cache after a scan: ${rebuilt.message}`);
38545
+ return [];
38546
+ }
38547
+ const now = cachedClasses(projectPath);
38548
+ return now === null ? [] : lost.filter((name) => now.has(name));
38549
+ }
38534
38550
  async alsoSayWhatTheEditorCannotSee(answered, args) {
38535
38551
  const first = answered.content[0];
38536
38552
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -0,0 +1,294 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## What a caller writes for a property, read against the type that property declares, and the
5
+ ## question of whether it can be written at all.
6
+ ##
7
+ ## One parser for the three tools that write properties into files. There were three, each with its
8
+ ## own short list of tags it knew: scenes handled eighteen types, resources seven, animation tracks
9
+ ## three. A Quaternion keyframe was stored as the dictionary it arrived as, a Rect2 written to a
10
+ ## resource likewise, and both were saved that way, so the same value written through two tools
11
+ ## came out as two different things and only one of them was the value.
12
+ ##
13
+ ## The tags themselves are the shared serialiser's, so every shape a read answers with is a shape a
14
+ ## write accepts. What is here on top of it is the part only a property knows: the type wanted, so a
15
+ ## dictionary shaped like a vector or a bare pair of numbers can be read as one.
16
+
17
+ const Read = preload("reading.gd")
18
+ const Serialisation = preload("serialisation.gd")
19
+
20
+ # What each packed array holds, for the four whose elements are not types JSON has. A polygon is
21
+ # written as a list of pairs, and only the property knows that each pair is a Vector2 rather than a
22
+ # list of two numbers: without being told, every element was read as itself and the engine turned
23
+ # the lot into zeroes on the way into the property.
24
+ const PACKED_ELEMENTS: Dictionary = {
25
+ TYPE_PACKED_VECTOR2_ARRAY: TYPE_VECTOR2,
26
+ TYPE_PACKED_VECTOR3_ARRAY: TYPE_VECTOR3,
27
+ TYPE_PACKED_VECTOR4_ARRAY: TYPE_VECTOR4,
28
+ TYPE_PACKED_COLOR_ARRAY: TYPE_COLOR,
29
+ }
30
+
31
+ var _values: Serialisation = Serialisation.new()
32
+
33
+ # Why the value being parsed cannot be written, when the parse is what found out. A resource is
34
+ # built inside the parse and there is nowhere in a parsed value to put the reason it is wrong.
35
+ var _refused: String = ""
36
+
37
+
38
+ ## Turns what arrived over the wire into the Godot value a property wants.
39
+ ##
40
+ ## Three separate questions, asked in order, because a caller may say what it means in three
41
+ ## ways: a dictionary that names its own type, a dictionary shaped like the type the property
42
+ ## declares, or a bare array positional for a vector. Each is its own function; asking all three
43
+ ## in one was twenty-four exits deep and impossible to follow.
44
+ func parse(value: Variant, expected_type: int = TYPE_NIL) -> Variant:
45
+ if value is Dictionary:
46
+ var fields: Dictionary = value
47
+ var tagged: Array = _parse_tagged_dictionary(fields)
48
+ if tagged[0]:
49
+ return tagged[1]
50
+ return _parse_shaped_dictionary(fields, expected_type)
51
+ if value is Array:
52
+ var items: Array = value
53
+ return _parse_array(items, expected_type)
54
+ # The same fitting the running game does, so "2.5" written to a float property here and there
55
+ # mean the same thing. It leaves a value it cannot fit alone rather than casting it, which is
56
+ # what lets the write be refused instead of landing as whatever the cast produced.
57
+ return _values.fitted(value, expected_type)
58
+
59
+
60
+ ## Why [param property] on [param holder] cannot be given [param value], or "" when it can.
61
+ ##
62
+ ## Object.set converts rather than refuses, and the conversion is silent and lossy: a word written
63
+ ## to an int property is stored as 0, to a bool as true, to a Vector2 as (0, 0). The file is then
64
+ ## saved holding a value nobody asked for and the tool reports the change as made, which is worse
65
+ ## than refusing and worse than failing, because there is nothing anywhere to read afterwards that
66
+ ## says the value is not the one that was sent.
67
+ func cannot_hold(holder: String, property: String, value: Variant, expected_type: int) -> String:
68
+ var held: String = type_string(expected_type)
69
+ var cost: String = " Setting it would save a different value than the one asked for."
70
+ if not Serialisation.acceptable(value, expected_type):
71
+ var shape: String = "%s.%s is %s and the value given is %s, which cannot become one."
72
+ return shape % [holder, property, held, type_string(typeof(value))] + cost
73
+
74
+ # And the same question of each element, because a list is accepted for a packed array whatever
75
+ # is in it: a polygon written as [{"x": 1}] passes the check above and lands as one zero vector.
76
+ var element: int = Read.as_int(PACKED_ELEMENTS.get(expected_type, TYPE_NIL), TYPE_NIL)
77
+ if element == TYPE_NIL or not value is Array:
78
+ return ""
79
+ var items: Array = value
80
+ for index: int in items.size():
81
+ if not Serialisation.acceptable(items[index], element):
82
+ var shape: String = "%s.%s is %s and item %d of the list given is %s, not %s."
83
+ var named: Array = [
84
+ holder, property, held, index, type_string(typeof(items[index])), type_string(element)
85
+ ]
86
+ return shape % named + cost
87
+ return ""
88
+
89
+
90
+ ## Writes each of [param properties] onto [param target], stopping at the first it cannot write and
91
+ ## answering with why, or "" when every one of them went on.
92
+ ##
93
+ ## In the order they were given, because a property list is not fixed: a ShaderMaterial has no
94
+ ## `shader_parameter/tint` until its `shader` is set, so a pass that checked them all before writing
95
+ ## any would refuse the pair that works.
96
+ ##
97
+ ## Nothing here saves, so a refusal partway leaves the change in memory and the file as it was. The
98
+ ## caller returns the refusal instead of saving.
99
+ func write_all(target: Object, properties: Dictionary) -> String:
100
+ for key: Variant in properties:
101
+ var property: String = str(key)
102
+ if not has_property(target, property):
103
+ return "%s has no property %s" % [target.get_class(), property]
104
+ var expected_type: int = property_type(target, property)
105
+ var raw: Variant = properties[key]
106
+
107
+ # A resource-valued property takes the path of one, which is how a caller names a TileSet,
108
+ # a material or a theme: there is no other way to hand a tool a Resource.
109
+ if expected_type == TYPE_OBJECT and typeof(raw) == TYPE_STRING:
110
+ var found: Array = _resource_at(str(raw), property)
111
+ if not found[0]:
112
+ return str(found[1])
113
+ target.set(property, found[1])
114
+ continue
115
+
116
+ _refused = ""
117
+ var value: Variant = parse(raw, expected_type)
118
+ if not _refused.is_empty():
119
+ return _refused
120
+ var refusal: String = cannot_hold(target.get_class(), property, value, expected_type)
121
+ if not refusal.is_empty():
122
+ return refusal
123
+ target.set(property, value)
124
+ return ""
125
+
126
+
127
+ ## The resource [param path] names, as [found, resource or why not].
128
+ ##
129
+ ## The project boundary is enforced here as well as on the server, because only the engine knows
130
+ ## that this property is one holding a path: an absolute path and a user:// one both load, and
131
+ ## neither names a file this project owns. A path nothing is at is refused too, since Object.set
132
+ ## stores the null that load answers with and the file is saved having quietly lost the reference.
133
+ func _resource_at(path: String, property: String) -> Array:
134
+ if not (path.begins_with("res://") or path.begins_with("uid://")):
135
+ return [false, "%s takes a res:// or uid:// path, not %s" % [property, path]]
136
+ if path.split("/").has(".."):
137
+ return [false, "%s leaves the project: %s" % [property, path]]
138
+ if not ResourceLoader.exists(path):
139
+ return [false, "No resource at %s for %s" % [path, property]]
140
+ return [true, load(path)]
141
+
142
+
143
+ ## Whether [param target] declares [param property] at all. Object.set ignores a name it does not
144
+ ## know, so without this a typo saved a file that had not changed and reported it as one that had.
145
+ func has_property(target: Object, property: String) -> bool:
146
+ for declared: Dictionary in target.get_property_list():
147
+ if str(declared.get("name", "")) == property:
148
+ return true
149
+ return false
150
+
151
+
152
+ ## The type [param property] is declared as, or TYPE_NIL where nothing declares one.
153
+ func property_type(target: Object, property: String) -> int:
154
+ for declared: Dictionary in target.get_property_list():
155
+ if str(declared.get("name", "")) == property:
156
+ return Read.as_int(declared.get("type", TYPE_NIL), TYPE_NIL)
157
+ return TYPE_NIL
158
+
159
+
160
+ ## A dictionary carrying its own type name, as the serialiser writes it.
161
+ ##
162
+ ## Answers [handled, value] rather than just the value, because a handled tag may legitimately
163
+ ## produce null: a Resource with no path is "this is nothing", not "this is not mine". A tag
164
+ ## that names a type but lacks the keys to build it is left unhandled on purpose, so the caller
165
+ ## can still read it against the type the property declares.
166
+ func _parse_tagged_dictionary(value: Dictionary) -> Array:
167
+ var type_tag: String = str(value.get("_type", value.get("type", "")))
168
+ if type_tag == "Resource":
169
+ var resource_path: String = str(value.get("path", ""))
170
+ return [true, null if resource_path.is_empty() else load(resource_path)]
171
+
172
+ # The shared builder is the same one the answer was written by, so every shape it writes comes
173
+ # back as itself. `type` as the tag is read as `_type`: it is what these tools answered with
174
+ # before the serialisers were made one, and a caller who kept an answer can still send it.
175
+ var tagged: Dictionary = value
176
+ if not value.has("_type") and not type_tag.is_empty():
177
+ tagged = value.duplicate()
178
+ tagged["_type"] = type_tag
179
+ var rebuilt: Variant = _values.deserialize_value(tagged)
180
+ if not rebuilt is Dictionary:
181
+ return [true, rebuilt]
182
+
183
+ return _parse_new_resource(type_tag, value)
184
+
185
+
186
+ ## A tag naming a Resource class builds a fresh one, its other keys set as properties, so a
187
+ ## NavigationRegion2D can arrive with its NavigationPolygon and an AnimationTree with its root
188
+ ## state machine in the same add as any other property.
189
+ func _parse_new_resource(type_tag: String, value: Dictionary) -> Array:
190
+ if (
191
+ type_tag.is_empty()
192
+ or not ClassDB.class_exists(type_tag)
193
+ or not ClassDB.is_parent_class(type_tag, "Resource")
194
+ or not ClassDB.can_instantiate(type_tag)
195
+ ):
196
+ return [false, null]
197
+
198
+ var built: Resource = ClassDB.instantiate(type_tag)
199
+ var wanted: Dictionary = {}
200
+ for key: Variant in value:
201
+ var property: String = str(key)
202
+ if property != "_type" and property != "type":
203
+ wanted[property] = value[key]
204
+ var refused: String = write_all(built, wanted)
205
+ if not refused.is_empty():
206
+ _refused = refused
207
+ return [true, null]
208
+ return [true, built]
209
+
210
+
211
+ ## A dictionary with no tag, read against the type the property declares. Falls back to the
212
+ ## dictionary itself, since a property may genuinely want one.
213
+ func _parse_shaped_dictionary(value: Dictionary, expected_type: int) -> Variant:
214
+ match expected_type:
215
+ TYPE_VECTOR2:
216
+ if value.has("x") and value.has("y"):
217
+ return Vector2(Read.as_float(value["x"]), Read.as_float(value["y"]))
218
+ TYPE_VECTOR2I:
219
+ if value.has("x") and value.has("y"):
220
+ return Vector2i(Read.as_int(value["x"]), Read.as_int(value["y"]))
221
+ TYPE_VECTOR3:
222
+ if value.has("x") and value.has("y") and value.has("z"):
223
+ return Vector3(
224
+ Read.as_float(value["x"]), Read.as_float(value["y"]), Read.as_float(value["z"])
225
+ )
226
+ TYPE_VECTOR3I:
227
+ if value.has("x") and value.has("y") and value.has("z"):
228
+ return Vector3i(Read.as_int(value["x"]), Read.as_int(value["y"]), Read.as_int(value["z"]))
229
+ TYPE_VECTOR4:
230
+ if value.has("x") and value.has("y") and value.has("z") and value.has("w"):
231
+ return Vector4(
232
+ Read.as_float(value["x"]),
233
+ Read.as_float(value["y"]),
234
+ Read.as_float(value["z"]),
235
+ Read.as_float(value["w"])
236
+ )
237
+ TYPE_COLOR:
238
+ if value.has("r") and value.has("g") and value.has("b"):
239
+ return Color(
240
+ Read.as_float(value["r"]),
241
+ Read.as_float(value["g"]),
242
+ Read.as_float(value["b"]),
243
+ Read.as_float(value.get("a", 1), 1.0)
244
+ )
245
+ TYPE_RECT2:
246
+ if value.has("x") and value.has("y") and value.has("width") and value.has("height"):
247
+ return Rect2(
248
+ Read.as_float(value["x"]),
249
+ Read.as_float(value["y"]),
250
+ Read.as_float(value["width"]),
251
+ Read.as_float(value["height"])
252
+ )
253
+ TYPE_NODE_PATH:
254
+ if value.has("path"):
255
+ return NodePath(str(value["path"]))
256
+ return value
257
+
258
+
259
+ ## An array, either positional for a vector the property declares, or a list to parse per item.
260
+ func _parse_array(value: Array, expected_type: int) -> Variant:
261
+ if PACKED_ELEMENTS.has(expected_type):
262
+ var element: int = Read.as_int(PACKED_ELEMENTS[expected_type], TYPE_NIL)
263
+ return value.map(func(item: Variant) -> Variant: return parse(item, element))
264
+
265
+ match expected_type:
266
+ TYPE_VECTOR2:
267
+ if value.size() >= 2:
268
+ return Vector2(Read.as_float(value[0]), Read.as_float(value[1]))
269
+ TYPE_VECTOR2I:
270
+ if value.size() >= 2:
271
+ return Vector2i(Read.as_int(value[0]), Read.as_int(value[1]))
272
+ TYPE_VECTOR3:
273
+ if value.size() >= 3:
274
+ return Vector3(Read.as_float(value[0]), Read.as_float(value[1]), Read.as_float(value[2]))
275
+ TYPE_VECTOR3I:
276
+ if value.size() >= 3:
277
+ return Vector3i(Read.as_int(value[0]), Read.as_int(value[1]), Read.as_int(value[2]))
278
+ TYPE_VECTOR4:
279
+ if value.size() >= 4:
280
+ return Vector4(
281
+ Read.as_float(value[0]),
282
+ Read.as_float(value[1]),
283
+ Read.as_float(value[2]),
284
+ Read.as_float(value[3])
285
+ )
286
+ TYPE_COLOR:
287
+ if value.size() >= 3:
288
+ return Color(
289
+ Read.as_float(value[0]),
290
+ Read.as_float(value[1]),
291
+ Read.as_float(value[2]),
292
+ Read.as_float(value[3]) if value.size() >= 4 else 1.0
293
+ )
294
+ return value.map(func(item: Variant) -> Variant: return parse(item))
@@ -0,0 +1 @@
1
+ uid://c1wrzpz2b0nlh
@@ -10,6 +10,27 @@ extends RefCounted
10
10
 
11
11
  const Read = preload("reading.gd")
12
12
 
13
+ # What the engine carries from one to another without complaint, which is what a value written to a
14
+ # property or handed to a parameter may arrive as. Numbers include bool because the engine counts it
15
+ # as one, and the three text types are one string wearing three hats.
16
+ const NUMBERS: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]
17
+ const TEXTS: Array[int] = [TYPE_STRING, TYPE_STRING_NAME, TYPE_NODE_PATH]
18
+
19
+ # The types that cross as a plain JSON list and have to be built back into themselves. A list is
20
+ # what a caller can write by hand and what the serialiser answers with, and the parameter or
21
+ # property being written is the only thing that knows which of these it wanted.
22
+ const LIST_TYPES: Array[int] = [
23
+ TYPE_PACKED_INT32_ARRAY,
24
+ TYPE_PACKED_INT64_ARRAY,
25
+ TYPE_PACKED_FLOAT32_ARRAY,
26
+ TYPE_PACKED_FLOAT64_ARRAY,
27
+ TYPE_PACKED_STRING_ARRAY,
28
+ TYPE_PACKED_VECTOR2_ARRAY,
29
+ TYPE_PACKED_VECTOR3_ARRAY,
30
+ TYPE_PACKED_VECTOR4_ARRAY,
31
+ TYPE_PACKED_COLOR_ARRAY,
32
+ ]
33
+
13
34
  # What each Godot type becomes on the wire, as one table you can read rather than an order you
14
35
  # have to trust. Keyed on typeof() rather than written as a chain of `is` tests, because the
15
36
  # order mattered: Resource had to be tested before Object or every resource came back as a bare
@@ -163,6 +184,91 @@ func deserialize_value(value: Variant) -> Variant:
163
184
  return call(builder, fields)
164
185
 
165
186
 
187
+ ## Whether two values can be compared without the comparison itself failing.
188
+ ##
189
+ ## GDScript's `==` is not total. An Object against a String is a hard error rather than false, and
190
+ ## an error raised inside a game is not a wrong answer: it stops the game. A caller who asked
191
+ ## `runtime_wait until` for the wrong kind of value got their game held at a debugger break, every
192
+ ## later call answering "did not respond within 10000ms, it may be stuck in a long frame", and
193
+ ## nothing anywhere naming the argument that did it. A wrong argument costs a refusal, never the
194
+ ## session's game.
195
+ ##
196
+ ## Only an object is special. Every other pair the engine answers, false where they differ, and
197
+ ## refusing those would refuse calls that work: an int against a float is the same number to a
198
+ ## caller waiting for 1 on a property holding 1.0.
199
+ static func comparable(one: Variant, other: Variant) -> bool:
200
+ var left: int = typeof(one)
201
+ var right: int = typeof(other)
202
+ if left != TYPE_OBJECT and right != TYPE_OBJECT:
203
+ return true
204
+ # An object compares with another object and with null. Anything else is the error above.
205
+ return (left == TYPE_OBJECT or left == TYPE_NIL) and (right == TYPE_OBJECT or right == TYPE_NIL)
206
+
207
+
208
+ ## Whether [param value] can be given to something declared as [param type] without damage.
209
+ ##
210
+ ## Here rather than beside either caller because the running game and the editor ask it of the same
211
+ ## values and must not answer differently. What it protects against differs by side and both are
212
+ ## silent: `callv` and `==` raise inside a running game, which holds it at a debugger break, while
213
+ ## `Object.set` in the editor converts instead and writes the result to the scene file. A word where
214
+ ## an int goes is stored as 0, where a bool goes as true, where a Vector2 goes as (0, 0), and the
215
+ ## tool reports the change as made. The property keeps a value nobody asked for and the scene is
216
+ ## saved with it.
217
+ ##
218
+ ## A list of what is accepted rather than of what is refused, which is the way round that errs
219
+ ## safely. The engine's own conversion table is not readable from GDScript, so either list is a
220
+ ## guess at it; guessing narrow costs a caller a refusal naming both types, and guessing wide costs
221
+ ## them the damage above. A refusal is the cheaper mistake, and it is the one they can act on.
222
+ static func acceptable(value: Variant, type: int) -> bool:
223
+ if type == TYPE_NIL:
224
+ return true
225
+ var given: int = typeof(value)
226
+ if given == type:
227
+ return true
228
+ # null is a value for anything that holds an object, and for nothing else.
229
+ if given == TYPE_NIL:
230
+ return type == TYPE_OBJECT
231
+ if NUMBERS.has(given) and NUMBERS.has(type):
232
+ return true
233
+ if TEXTS.has(given) and TEXTS.has(type):
234
+ return true
235
+ return given == TYPE_ARRAY and LIST_TYPES.has(type)
236
+
237
+
238
+ ## A value from the wire fitted to the type a property or parameter declares. Arguments arrive
239
+ ## as strings often enough, and callv refuses a "2.0" where a float is wanted, so a string that
240
+ ## reads as the wanted scalar is read as one.
241
+ ##
242
+ ## The conversion is also what restores a packed array's exact type. Those cross as the plain JSON
243
+ ## lists they fit into, so what comes back from the wire is an Array, and the parameter is what says
244
+ ## it was a PackedStringArray.
245
+ func fitted(value: Variant, type: int) -> Variant:
246
+ var rebuilt: Variant = deserialize_value(value)
247
+ if type == TYPE_NIL or typeof(rebuilt) == type:
248
+ return rebuilt
249
+
250
+ if rebuilt is Array and LIST_TYPES.has(type):
251
+ return type_convert(rebuilt, type)
252
+
253
+ var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
254
+ if not simple.has(type) or not simple.has(typeof(rebuilt)):
255
+ return rebuilt
256
+
257
+ if rebuilt is String and type != TYPE_STRING:
258
+ var text: String = rebuilt
259
+ var parsed: Variant = JSON.parse_string(text)
260
+ # A string that does not read as the type wanted is handed back as the string it is, so
261
+ # whoever asked can refuse it. type_convert answers 0 for "not an index" and true for any
262
+ # text at all, and a caller who sent a word got back a number they never sent: get_child
263
+ # answered about child 0 with no refusal and no note, which is a halt traded for a plausible
264
+ # wrong answer, and the wrong answer is the worse of the two.
265
+ if typeof(parsed) == TYPE_NIL or typeof(parsed) == TYPE_STRING:
266
+ return rebuilt
267
+ rebuilt = parsed
268
+
269
+ return type_convert(rebuilt, type)
270
+
271
+
166
272
  ## Whether [param fields] carries every key of any one of [param alternatives].
167
273
  ##
168
274
  ## More than one spelling because a rectangle has two: the two corners it is answered with, and the
@@ -3,9 +3,11 @@ extends Node
3
3
 
4
4
  ## Animations and animation trees, edited in the open editor.
5
5
 
6
+ const PropertyValues = preload("../property_values.gd")
6
7
  const Read = preload("../reading.gd")
7
8
 
8
9
  var _editor_plugin: EditorPlugin = null
10
+ var _properties: PropertyValues = PropertyValues.new()
9
11
 
10
12
 
11
13
  func set_editor_plugin(plugin: EditorPlugin) -> void:
@@ -70,33 +72,14 @@ func _find_node(root: Node, path: String) -> Node:
70
72
  return root.get_node_or_null(path)
71
73
 
72
74
 
75
+ ## A keyframe value or a method argument, read as whatever it says it is.
76
+ ##
77
+ ## No type to read it against, unlike a property write: what a track animates is named by a path
78
+ ## that has still to be resolved against a scene this tool does not hold. So the tags are what
79
+ ## decide, which is why they have to be all of them. Three were handled here, and a Quaternion,
80
+ ## a Rect2 or a Transform2D keyframe was stored as the dictionary it arrived as.
73
81
  func _parse_value(value: Variant) -> Variant:
74
- if typeof(value) == TYPE_DICTIONARY:
75
- var fields: Dictionary = value
76
- if fields.has("type") or fields.has("_type"):
77
- var t: Variant = fields.get("type", fields.get("_type", ""))
78
- match t:
79
- "Vector2":
80
- return Vector2(Read.as_float(fields.get("x", 0)), Read.as_float(fields.get("y", 0)))
81
- "Vector3":
82
- return Vector3(
83
- Read.as_float(fields.get("x", 0)),
84
- Read.as_float(fields.get("y", 0)),
85
- Read.as_float(fields.get("z", 0))
86
- )
87
- "Color":
88
- return Color(
89
- Read.as_float(fields.get("r", 1), 1.0),
90
- Read.as_float(fields.get("g", 1), 1.0),
91
- Read.as_float(fields.get("b", 1), 1.0),
92
- Read.as_float(fields.get("a", 1), 1.0)
93
- )
94
- if typeof(value) == TYPE_ARRAY:
95
- var result: Array = []
96
- for item: Variant in value:
97
- result.append(_parse_value(item))
98
- return result
99
- return value
82
+ return _properties.parse(value)
100
83
 
101
84
 
102
85
  func _parse_json_maybe(value: Variant) -> Variant:
@@ -3,6 +3,7 @@ extends Node
3
3
 
4
4
  ## Resource files, shaders, tilesets and themes, written through the open editor.
5
5
 
6
+ const PropertyValues = preload("../property_values.gd")
6
7
  const Read = preload("../reading.gd")
7
8
 
8
9
  # Shader templates. Written as real multi-line source rather than escaped one-liners so that
@@ -48,6 +49,7 @@ void fragment() {
48
49
  """
49
50
 
50
51
  var _editor_plugin: EditorPlugin = null
52
+ var _properties: PropertyValues = PropertyValues.new()
51
53
 
52
54
 
53
55
  func set_editor_plugin(plugin: EditorPlugin) -> void:
@@ -70,57 +72,9 @@ func _refresh_filesystem() -> void:
70
72
  EditorInterface.get_resource_filesystem().scan()
71
73
 
72
74
 
73
- func _parse_value(value: Variant) -> Variant:
74
- if typeof(value) == TYPE_DICTIONARY:
75
- var fields: Dictionary = value
76
- if fields.has("type") or fields.has("_type"):
77
- var t: Variant = fields.get("type", fields.get("_type", ""))
78
- match t:
79
- "Vector2":
80
- return Vector2(Read.as_float(fields.get("x", 0)), Read.as_float(fields.get("y", 0)))
81
- "Vector3":
82
- return Vector3(
83
- Read.as_float(fields.get("x", 0)),
84
- Read.as_float(fields.get("y", 0)),
85
- Read.as_float(fields.get("z", 0))
86
- )
87
- "Color":
88
- return Color(
89
- Read.as_float(fields.get("r", 1), 1.0),
90
- Read.as_float(fields.get("g", 1), 1.0),
91
- Read.as_float(fields.get("b", 1), 1.0),
92
- Read.as_float(fields.get("a", 1), 1.0)
93
- )
94
- "Vector2i":
95
- return Vector2i(Read.as_int(fields.get("x", 0)), Read.as_int(fields.get("y", 0)))
96
- "Vector3i":
97
- return Vector3i(
98
- Read.as_int(fields.get("x", 0)),
99
- Read.as_int(fields.get("y", 0)),
100
- Read.as_int(fields.get("z", 0))
101
- )
102
- "Rect2":
103
- return Rect2(
104
- Read.as_float(fields.get("x", 0)),
105
- Read.as_float(fields.get("y", 0)),
106
- Read.as_float(fields.get("width", 0)),
107
- Read.as_float(fields.get("height", 0))
108
- )
109
- "NodePath":
110
- return NodePath(str(fields.get("path", "")))
111
- if typeof(value) == TYPE_ARRAY:
112
- var result: Array = []
113
- for item: Variant in value:
114
- result.append(_parse_value(item))
115
- return result
116
- return value
117
-
118
-
119
- func _set_resource_properties(resource: Resource, properties: Variant) -> void:
120
- var props: Dictionary = _parse_properties_dict(properties)
121
- for key: Variant in props:
122
- var named: String = str(key)
123
- resource.set(named, _parse_value(props[key]))
75
+ ## Set each property, answering with what went wrong or "" when nothing did.
76
+ func _set_resource_properties(resource: Resource, properties: Variant) -> String:
77
+ return _properties.write_all(resource, _parse_properties_dict(properties))
124
78
 
125
79
 
126
80
  func _parse_properties_dict(raw: Variant) -> Dictionary:
@@ -175,7 +129,9 @@ func create_resource(args: Dictionary) -> Dictionary:
175
129
  resource.set_script(script_obj)
176
130
 
177
131
  if args.has("properties"):
178
- _set_resource_properties(resource, args.get("properties"))
132
+ var refused: String = _set_resource_properties(resource, args.get("properties"))
133
+ if not refused.is_empty():
134
+ return {"ok": false, "error": refused}
179
135
 
180
136
  var save_result: Error = ResourceSaver.save(resource, res_path)
181
137
  if save_result != OK:
@@ -194,7 +150,9 @@ func modify_resource(args: Dictionary) -> Dictionary:
194
150
  if resource == null:
195
151
  return {"ok": false, "error": "Resource not found", "resourcePath": res_path}
196
152
 
197
- _set_resource_properties(resource, args.get("properties", ""))
153
+ var refused: String = _set_resource_properties(resource, args.get("properties", ""))
154
+ if not refused.is_empty():
155
+ return {"ok": false, "error": refused}
198
156
  var save_result: Error = ResourceSaver.save(resource, res_path)
199
157
  if save_result != OK:
200
158
  return {"ok": false, "error": "Failed to save resource", "code": save_result}