gdharness 0.13.2 → 0.13.4

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.
@@ -4,12 +4,31 @@ extends Node
4
4
  ## Scenes and their nodes, edited in the open editor and read back from what it holds.
5
5
 
6
6
  const Read = preload("../reading.gd")
7
+ const Serialisation = preload("../serialisation.gd")
7
8
 
8
9
  var _editor_plugin: EditorPlugin = null
10
+ var _values: Serialisation = Serialisation.new()
11
+
12
+ ## How many scans the editor has finished, counted off its own signal, and what the count was when
13
+ ## a scan was last asked for. A queued scan has not started, so the flags the engine offers read
14
+ ## the same as one that is over; the count is what tells the two apart.
15
+ var _scans_finished: int = 0
16
+ var _scans_finished_at_request: int = 0
17
+ var _scan_pending: bool = false
9
18
 
10
19
 
11
20
  func set_editor_plugin(plugin: EditorPlugin) -> void:
12
21
  _editor_plugin = plugin
22
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
23
+ if not filesystem.filesystem_changed.is_connected(_scan_finished):
24
+ var watched: int = filesystem.filesystem_changed.connect(_scan_finished)
25
+ if watched != OK:
26
+ push_error("gdharness could not watch for finished scans: %d" % watched)
27
+
28
+
29
+ func _scan_finished() -> void:
30
+ _scans_finished += 1
31
+ _scan_pending = false
13
32
 
14
33
 
15
34
  func _refresh_and_reload(scene_path: String) -> void:
@@ -110,66 +129,23 @@ func _parse_value(value: Variant, expected_type: int = TYPE_NIL) -> Variant:
110
129
  ## that names a type but lacks the keys to build it is left unhandled on purpose, so the caller
111
130
  ## can still read it against the type the property declares.
112
131
  func _parse_tagged_dictionary(value: Dictionary) -> Array:
113
- var type_tag: String = ""
114
- if value.has("type"):
115
- type_tag = str(value["type"])
116
- elif value.has("_type"):
117
- type_tag = str(value["_type"])
118
-
119
- match type_tag:
120
- "Vector2":
121
- return [true, Vector2(Read.as_float(value.get("x", 0)), Read.as_float(value.get("y", 0)))]
122
- "Vector3":
123
- return [
124
- true,
125
- Vector3(
126
- Read.as_float(value.get("x", 0)),
127
- Read.as_float(value.get("y", 0)),
128
- Read.as_float(value.get("z", 0))
129
- )
130
- ]
131
- "Color":
132
- return [
133
- true,
134
- Color(
135
- Read.as_float(value.get("r", 1), 1.0),
136
- Read.as_float(value.get("g", 1), 1.0),
137
- Read.as_float(value.get("b", 1), 1.0),
138
- Read.as_float(value.get("a", 1), 1.0)
139
- )
140
- ]
141
- "Vector2i":
142
- return [true, Vector2i(Read.as_int(value.get("x", 0)), Read.as_int(value.get("y", 0)))]
143
- "Vector3i":
144
- return [
145
- true,
146
- Vector3i(
147
- Read.as_int(value.get("x", 0)),
148
- Read.as_int(value.get("y", 0)),
149
- Read.as_int(value.get("z", 0))
150
- )
151
- ]
152
- "Rect2":
153
- return [
154
- true,
155
- Rect2(
156
- Read.as_float(value.get("x", 0)),
157
- Read.as_float(value.get("y", 0)),
158
- Read.as_float(value.get("width", 0)),
159
- Read.as_float(value.get("height", 0))
160
- )
161
- ]
162
- "Transform2D":
163
- return _parse_transform2d(value)
164
- "Transform3D":
165
- return _parse_transform3d(value)
166
- "NodePath":
167
- return [true, NodePath(str(value.get("path", "")))]
168
- "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
- return _parse_new_resource(type_tag, value)
132
+ var type_tag: String = str(value.get("_type", value.get("type", "")))
133
+ if type_tag == "Resource":
134
+ var resource_path: String = str(value.get("path", ""))
135
+ return [true, null if resource_path.is_empty() else load(resource_path)]
136
+
137
+ # The shared builder is the same one the answer was written by, so every shape it writes comes
138
+ # back as itself. `type` as the tag is read as `_type`: it is what this tool answered with
139
+ # before the serialisers were made one, and a caller who kept an answer can still send it.
140
+ var tagged: Dictionary = value
141
+ if not value.has("_type") and not type_tag.is_empty():
142
+ tagged = value.duplicate()
143
+ tagged["_type"] = type_tag
144
+ var rebuilt: Variant = _values.deserialize_value(tagged)
145
+ if not rebuilt is Dictionary:
146
+ return [true, rebuilt]
147
+
148
+ return _parse_new_resource(type_tag, value)
173
149
 
174
150
 
175
151
  ## A tag naming a Resource class builds a fresh one, its other keys set as properties, so a
@@ -193,47 +169,6 @@ func _parse_new_resource(type_tag: String, value: Dictionary) -> Array:
193
169
  return [true, built]
194
170
 
195
171
 
196
- func _parse_transform2d(value: Dictionary) -> Array:
197
- if not (value.has("x") and value.has("y") and value.has("origin")):
198
- return [false, null]
199
-
200
- var basis_x: Dictionary = value["x"]
201
- var basis_y: Dictionary = value["y"]
202
- var origin: Dictionary = value["origin"]
203
- return [
204
- true,
205
- Transform2D(
206
- Vector2(Read.as_float(basis_x.get("x", 1), 1.0), Read.as_float(basis_x.get("y", 0))),
207
- Vector2(Read.as_float(basis_y.get("x", 0)), Read.as_float(basis_y.get("y", 1), 1.0)),
208
- Vector2(Read.as_float(origin.get("x", 0)), Read.as_float(origin.get("y", 0)))
209
- )
210
- ]
211
-
212
-
213
- func _parse_transform3d(value: Dictionary) -> Array:
214
- if not (value.has("basis") and value.has("origin")):
215
- return [false, null]
216
-
217
- var b: Dictionary = value["basis"]
218
- var o: Dictionary = value["origin"]
219
- var x: Dictionary = b.get("x", {})
220
- var y: Dictionary = b.get("y", {})
221
- var z: Dictionary = b.get("z", {})
222
- var basis: Basis = Basis(
223
- Vector3(
224
- Read.as_float(x.get("x", 1), 1.0), Read.as_float(x.get("y", 0)), Read.as_float(x.get("z", 0))
225
- ),
226
- Vector3(
227
- Read.as_float(y.get("x", 0)), Read.as_float(y.get("y", 1), 1.0), Read.as_float(y.get("z", 0))
228
- ),
229
- Vector3(Read.as_float(z.get("x", 0)), Read.as_float(z.get("y", 0)), Read.as_float(z.get("z", 1), 1.0))
230
- )
231
- var origin: Vector3 = Vector3(
232
- Read.as_float(o.get("x", 0)), Read.as_float(o.get("y", 0)), Read.as_float(o.get("z", 0))
233
- )
234
- return [true, Transform3D(basis, origin)]
235
-
236
-
237
172
  ## A dictionary with no tag, read against the type the property declares. Falls back to the
238
173
  ## dictionary itself, since a property may genuinely want one.
239
174
  func _parse_shaped_dictionary(value: Dictionary, expected_type: int) -> Variant:
@@ -299,54 +234,6 @@ func _get_property_type(node: Node, prop_name: String) -> int:
299
234
  return TYPE_NIL
300
235
 
301
236
 
302
- func _serialize_value(value: Variant) -> Variant:
303
- match typeof(value):
304
- TYPE_VECTOR2:
305
- return {"type": "Vector2", "x": value.x, "y": value.y}
306
- TYPE_VECTOR3:
307
- return {"type": "Vector3", "x": value.x, "y": value.y, "z": value.z}
308
- TYPE_COLOR:
309
- return {"type": "Color", "r": value.r, "g": value.g, "b": value.b, "a": value.a}
310
- TYPE_VECTOR2I:
311
- return {"type": "Vector2i", "x": value.x, "y": value.y}
312
- TYPE_VECTOR3I:
313
- return {"type": "Vector3i", "x": value.x, "y": value.y, "z": value.z}
314
- TYPE_RECT2:
315
- return {
316
- "type": "Rect2",
317
- "x": value.position.x,
318
- "y": value.position.y,
319
- "width": value.size.x,
320
- "height": value.size.y
321
- }
322
- TYPE_NODE_PATH:
323
- return {"type": "NodePath", "path": str(value)}
324
- TYPE_TRANSFORM2D:
325
- return {
326
- "type": "Transform2D",
327
- "x": {"x": value.x.x, "y": value.x.y},
328
- "y": {"x": value.y.x, "y": value.y.y},
329
- "origin": {"x": value.origin.x, "y": value.origin.y}
330
- }
331
- TYPE_TRANSFORM3D:
332
- return {
333
- "type": "Transform3D",
334
- "basis":
335
- {
336
- "x": {"x": value.basis.x.x, "y": value.basis.x.y, "z": value.basis.x.z},
337
- "y": {"x": value.basis.y.x, "y": value.basis.y.y, "z": value.basis.y.z},
338
- "z": {"x": value.basis.z.x, "y": value.basis.z.y, "z": value.basis.z.z}
339
- },
340
- "origin": {"x": value.origin.x, "y": value.origin.y, "z": value.origin.z}
341
- }
342
- TYPE_OBJECT:
343
- if value and value is Resource and value.resource_path:
344
- return {"type": "Resource", "path": value.resource_path}
345
- return null
346
- _:
347
- return value
348
-
349
-
350
237
  ## Set each property, answering with what went wrong or "" when nothing did.
351
238
  ##
352
239
  ## A property the node does not have, and a resource path nothing is at, are both refused: Object
@@ -431,7 +318,7 @@ func _build_node_tree(
431
318
  var pn: String = str(p.get("name", ""))
432
319
  if pn.is_empty():
433
320
  continue
434
- props[pn] = _serialize_value(node.get(pn))
321
+ props[pn] = _values.serialize_value(node.get(pn))
435
322
  data["properties"] = props
436
323
 
437
324
  if depth >= 0 and current_depth >= depth:
@@ -732,7 +619,7 @@ func get_node_properties(args: Dictionary) -> Dictionary:
732
619
  var default_val: Variant = defaults.get(prop_name)
733
620
  if current_val == default_val:
734
621
  continue
735
- props[prop_name] = _serialize_value(current_val)
622
+ props[prop_name] = _values.serialize_value(current_val)
736
623
 
737
624
  if defaults:
738
625
  defaults.queue_free()
@@ -928,8 +815,28 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
928
815
 
929
816
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
930
817
  if not Read.as_bool(args.get("statusOnly", false)):
818
+ _scans_finished_at_request = _scans_finished
819
+ _scan_pending = true
931
820
  filesystem.scan()
932
821
 
933
822
  # Importing is reported separately from scanning, and a class is not registered until
934
823
  # both are done, so a caller watching only one of them can look too early.
935
- return {"ok": true, "scanning": filesystem.is_scanning(), "importing": filesystem.is_importing()}
824
+ #
825
+ # `pending` is the third state, and it is the one that was missing. A scan is queued rather
826
+ # than run, so for the first frames after asking, both flags are false and the editor has not
827
+ # started: a caller polling them reads "finished" off a scan that has not begun. Measured, a
828
+ # headless editor of 400 scripts takes 1619ms and answered honestly, while a downstream editor
829
+ # of 376 classes answered finished 296ms in and then wrote its own stale class list over the
830
+ # cache that had just been corrected. Whoever reads this is waiting for the scan to have
831
+ # landed, so "not yet" and "over" must not be spelled the same.
832
+ var scanning: bool = filesystem.is_scanning()
833
+ var importing: bool = filesystem.is_importing()
834
+ if scanning or importing or _scans_finished != _scans_finished_at_request:
835
+ _scan_pending = false
836
+ return {
837
+ "ok": true,
838
+ "scanning": scanning,
839
+ "importing": importing,
840
+ "pending": _scan_pending,
841
+ "scansFinished": _scans_finished,
842
+ }
@@ -0,0 +1,58 @@
1
+ @tool
2
+ extends Node
3
+
4
+ ## Project settings read from the open editor, for a caller that asked for the editor's answer.
5
+ ##
6
+ ## The same question answered headless starts an engine, loads project.godot from disk and exits.
7
+ ## This answers from the ProjectSettings the editor already has, which costs nothing and is not the
8
+ ## same thing: an editor holds what it has been told, including changes nobody has saved yet. Which
9
+ ## of the two a caller wants is theirs to say, so the server only comes here when asked, and the
10
+ ## difference is the reason the argument exists rather than an inconvenience to paper over.
11
+ ##
12
+ ## Every value goes through the same serialiser the headless answer uses, because the two are one
13
+ ## question asked of two places: a value spelled differently here would read as the editor and the
14
+ ## file disagreeing about something they agree about.
15
+
16
+ const Read = preload("../reading.gd")
17
+ const Serialisation = preload("../serialisation.gd")
18
+
19
+ var _values: Serialisation = Serialisation.new()
20
+
21
+
22
+ func get_project_setting(args: Dictionary) -> Dictionary:
23
+ var prefix: String = str(args.get("prefix", ""))
24
+ if not prefix.is_empty():
25
+ return _settings_under(prefix)
26
+
27
+ var setting: String = str(args.get("setting", ""))
28
+ if setting.is_empty():
29
+ return {"ok": false, "error": "setting is required, or prefix to read every setting under one"}
30
+
31
+ var exists: bool = ProjectSettings.has_setting(setting)
32
+ var result: Dictionary = {"ok": true, "setting_path": setting, "exists": exists, "value": null}
33
+ if exists:
34
+ result["value"] = _values.serialize_value(ProjectSettings.get_setting(setting))
35
+ else:
36
+ result["message"] = "Setting does not exist"
37
+ return result
38
+
39
+
40
+ ## Every setting under [prefix] with the type the engine registers for each, which is the half a
41
+ ## name hides: a family of levels can hold a setting that is a bool, and writing a level over it
42
+ ## looks like it worked.
43
+ func _settings_under(prefix: String) -> Dictionary:
44
+ var found: Array[Dictionary] = []
45
+ for property: Dictionary in ProjectSettings.get_property_list():
46
+ var setting: String = str(property.get("name", ""))
47
+ if not setting.begins_with(prefix):
48
+ continue
49
+ var kind: int = Read.as_int(property.get("type", TYPE_NIL), TYPE_NIL)
50
+ var described: Dictionary = {
51
+ "setting": setting,
52
+ "type": type_string(kind),
53
+ "value": _values.serialize_value(ProjectSettings.get_setting(setting)),
54
+ }
55
+ found.append(described)
56
+
57
+ found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["setting"] < b["setting"])
58
+ return {"ok": true, "prefix": prefix, "count": found.size(), "settings": found}
@@ -0,0 +1 @@
1
+ uid://ddk0cjbgdv1f4
@@ -1,15 +1,22 @@
1
1
  extends RefCounted
2
2
 
3
- # A value out of a request that arrived as JSON, as the type the caller wants.
3
+ # A value out of a dictionary that came from JSON, as the type the caller wants.
4
+ #
5
+ # This file is the one beside the operations, and `bun run sync:gd` copies it into each addon,
6
+ # because an addon is installed as a directory and cannot preload out of one. The runtime addon's
7
+ # copy ships inside exported games and the editor addon's does not, which is the other reason
8
+ # neither of them reaches into the other.
4
9
  #
5
10
  # `int(value)`, `float(value)` and `bool(value)` take a Variant, and a project holding
6
- # `unsafe_call_argument` at error level will not compile a script that hands one over. The
7
- # conversion is the same; what changes is that the type is established first, on a typed local, and
8
- # the conversion happens from that.
11
+ # `unsafe_call_argument` at error level will not compile a script that hands one over. These
12
+ # scripts are compiled under the target project's warning levels rather than under this package's,
13
+ # because `--path` makes that project the loaded one and the file's living in the npm cache
14
+ # changes nothing: one project turning that warning on lost every headless operation at once.
9
15
  #
10
- # A copy rather than a preload of the one beside the operations, because an addon is installed as a
11
- # directory and has to hold everything it uses. This one ships inside exported games, so it must not
12
- # reach into the editor addon, which does not.
16
+ # The conversion itself is the same. What changes is that the type is established first, on a
17
+ # typed local, and the conversion happens from that. Here rather than at each of the thirty-one
18
+ # places that read a parameter, because thirty-one is how many chances there are to write the old
19
+ # form again, and the error does not appear until somebody sets the warning.
13
20
  #
14
21
  # `fallback` answers a value that is there and is null, which is a caller sending JSON null where a
15
22
  # number was expected. Absent keys never reach here: the callers pass their own default to
@@ -734,9 +734,24 @@ func call_method(params: Dictionary) -> Dictionary:
734
734
  if not holder.has_method(named):
735
735
  return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
736
736
 
737
+ # Checked before the call rather than left to it. `callv` raises inside the game when an
738
+ # argument cannot be converted, and an error raised in a game somebody is playing holds it at a
739
+ # debugger break: every later call then reports a game that is not responding, which points
740
+ # nowhere near the argument. A wrong argument costs a refusal, never the session's game.
737
741
  var deserialized_args: Array = []
738
742
  for index: int in args.size():
739
- deserialized_args.append(_values.fitted(args[index], _values.parameter_type(holder, named, index)))
743
+ var wants: int = _values.parameter_type(holder, named, index)
744
+ var given: Variant = _values.fitted(args[index], wants)
745
+ if not Values.acceptable(given, wants):
746
+ return {
747
+ "type": "error",
748
+ "message":
749
+ (
750
+ "%s.%s takes %s as argument %d and was given %s, which cannot be converted."
751
+ % [reached["called"], named, type_string(wants), index + 1, type_string(typeof(given))]
752
+ )
753
+ }
754
+ deserialized_args.append(given)
740
755
 
741
756
  var result: Variant = holder.callv(named, deserialized_args)
742
757