gdharness 1.2.37 → 1.2.39

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.
@@ -90,10 +90,41 @@ static func default_of(object: Object, name: String) -> Variant:
90
90
  if script != null:
91
91
  for declared: Dictionary in script.get_script_property_list():
92
92
  if str(declared.get("name", "")) == name:
93
- return script.get_property_default_value(name)
93
+ var given: Variant = script.get_property_default_value(name)
94
+ if given != null:
95
+ return given
96
+ return _initialised(script, name, Read.as_int(declared.get("type", TYPE_NIL)))
94
97
  return ClassDB.class_get_property_default_value(object.get_class(), name)
95
98
 
96
99
 
100
+ ## What the declaration of [param name] in [param script] or a script it extends initialises it to,
101
+ ## when that is a value written out, such as [code]PackedStringArray()[/code], or null. The engine
102
+ ## reports a default only for an initializer it folded into a constant, and it folds no constructor
103
+ ## call, so it gives none for such a property; the saver then writes it whatever it holds, and the
104
+ ## line read as a change some script had made.
105
+ static func _initialised(script: Script, name: String, type: int) -> Variant:
106
+ var declaration: RegEx = RegEx.create_from_string(
107
+ "(?m)^[ \\t]*(?:@[^\\n]*?\\s)?var\\s+" + name + "\\b[^=\\n]*?=\\s*(.+?)\\s*:?[ \\t]*$"
108
+ )
109
+ var holder: Script = script
110
+ while holder != null:
111
+ var found: RegExMatch = declaration.search(holder.source_code)
112
+ if found != null:
113
+ var value: Variant = str_to_var(found.get_string(1))
114
+ if value == null or type == TYPE_NIL or typeof(value) == type:
115
+ return value
116
+ return type_convert(value, type)
117
+ holder = holder.get_base_script()
118
+ return null
119
+
120
+
121
+ ## Whether [param object] holds its default for [param name], so a file that newly writes it says
122
+ ## nothing a script did: the saver writes a property whose default the engine cannot report whatever
123
+ ## it holds, the editor's save as well.
124
+ static func at_default(object: Object, name: String) -> bool:
125
+ return PropertyValues.same(object.get(name), default_of(object, name))
126
+
127
+
97
128
  ## The ids, in the file, of the embedded resources [param property] reaches into on [param object],
98
129
  ## such as the material of [code]material:albedo_color[/code], as a set.
99
130
  static func reached(object: Object, property: String) -> Dictionary:
@@ -295,17 +326,23 @@ static func settle_saved(
295
326
  ) -> String:
296
327
  for attempt: int in 2:
297
328
  var after: String = FileAccess.get_file_as_string(path)
298
- var embedded: Array[Array] = embedded_changes(before, after, reached_ids)
329
+ var by_id: Dictionary = embedded_in(values, {})
330
+ var embedded: Array[Array] = []
331
+ for found: Array in embedded_changes(before, after, reached_ids):
332
+ var holder: Resource = by_id.get(found[0])
333
+ if found[2] or holder == null or not at_default(holder, str(found[1])):
334
+ embedded.append(found)
299
335
  var top: Array[Array] = []
300
336
  if main != null:
301
- top = main_changes(before, after, asked)
337
+ for found: Array in main_changes(before, after, asked):
338
+ if found[1] or not at_default(main, str(found[0])):
339
+ top.append(found)
302
340
  if embedded.is_empty() and top.is_empty():
303
341
  return ""
304
342
  if attempt == 1:
305
343
  if not embedded.is_empty():
306
344
  return unasked("The embedded resource " + str(embedded[0][0]), str(embedded[0][1]))
307
345
  return unasked(path, str(top[0][0]))
308
- var by_id: Dictionary = embedded_in(values, {})
309
346
  for found: Array in embedded:
310
347
  var id: String = found[0]
311
348
  var name: String = found[1]
@@ -26,6 +26,9 @@ extends RefCounted
26
26
  ## anything is saved, and a write is refused when that does not hold. See [HeadlessCheck].
27
27
 
28
28
  const HeadlessCheck = preload("headless_check.gd")
29
+
30
+ ## How many times what scripts changed is put back before a write is refused for it.
31
+ const PUTTING_BACK_PASSES: int = 4
29
32
  const PropertyValues = preload("property_values.gd")
30
33
 
31
34
  ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
@@ -235,6 +238,60 @@ static func uncompiled(script: Script, script_path: String) -> String:
235
238
  )
236
239
 
237
240
 
241
+ ## The script a project declares [param type_name] in with class_name, as {"script", "path",
242
+ ## "base"} with base the engine class its objects are made as, or {"error"} when that script cannot
243
+ ## make one, or an empty dictionary when no script declares it. The editor's create dialogs offer
244
+ ## these classes beside the engine's, and ClassDB knows only the engine's.
245
+ static func script_class(type_name: String, kind: String) -> Dictionary:
246
+ for entry: Dictionary in ProjectSettings.get_global_class_list():
247
+ if str(entry.get("class", "")) != type_name:
248
+ continue
249
+ var declaring: String = str(entry.get("path", ""))
250
+ if entry.get("is_abstract", false) == true:
251
+ return {
252
+ "error":
253
+ "%s, declared in %s, is abstract, so nothing can be made of it." % [type_name, declaring]
254
+ }
255
+ var script: Script = load(declaring) as Script
256
+ if script == null:
257
+ return {"error": "%s is declared in %s, which does not load." % [type_name, declaring]}
258
+ var failed: String = uncompiled(script, declaring)
259
+ if not failed.is_empty():
260
+ return {"error": failed}
261
+ var base: String = str(script.get_instance_base_type())
262
+ if not ClassDB.is_parent_class(base, kind):
263
+ return {
264
+ "error":
265
+ "%s, declared in %s, extends %s, which is not a %s." % [type_name, declaring, base, kind]
266
+ }
267
+ return {"script": script, "path": declaring, "base": base}
268
+ return {}
269
+
270
+
271
+ ## The engine class and the script a create makes for [param type_name] and [param given], the
272
+ ## res:// path of the script the call named or "", as {"type", "script"}, or {"error"}. A class_name
273
+ ## a project script declares is that script on its engine class; [param argument] names the
274
+ ## argument the type came in, for the refusal when the call also named another script.
275
+ static func made_as(type_name: String, given: String, kind: String, argument: String) -> Dictionary:
276
+ if ClassDB.class_exists(type_name):
277
+ return {"type": type_name, "script": given}
278
+ var declared: Dictionary = script_class(type_name, kind)
279
+ if declared.has("error"):
280
+ return declared
281
+ if declared.is_empty():
282
+ return {"type": type_name, "script": given}
283
+ var declaring: String = str(declared["path"])
284
+ if not given.is_empty() and given != declaring:
285
+ return {
286
+ "error":
287
+ (
288
+ "%s %s is the class %s declares, and script names %s: pass one of them."
289
+ % [argument, type_name, declaring, given]
290
+ )
291
+ }
292
+ return {"type": str(declared["base"]), "script": declaring}
293
+
294
+
238
295
  ## Why no node can be called [param node_name], wherever it sits, or "".
239
296
  static func unfit_name(node_name: String) -> String:
240
297
  if node_name.is_empty():
@@ -459,30 +516,34 @@ static func unsaved(scene_path: String) -> bool:
459
516
  ## On a node the tool did not ask to change, a difference is put back to what the file holds, or, for
460
517
  ## a property the file does not set, to what the node would load with without it. On a node it did
461
518
  ## ask to change, through a script that is not a tool, every property it did not name is put back to
462
- ## what it held before. Packed again afterwards, since a setter can change one property through
463
- ## another, and refused if anything is still different.
519
+ ## what it held before. Packed again afterwards and put back again until nothing differs, since
520
+ ## putting one property back can run a setter that changes another already put back (a script
521
+ ## setting speed in _init, whose setter tints modulate), and refused if something still does.
464
522
  func _settled(packed: PackedScene) -> Array:
465
- var stray: Array[Array] = _strays(packed.get_state())
466
- var moved: Array[Array] = _moved_by_scripts()
467
- if stray.is_empty() and moved.is_empty():
468
- return [packed, ""]
469
- for found: Array in stray:
470
- var node: Node = found[0]
471
- var name: String = found[1]
472
- node.set(name, found[3] if found[2] else _unset_value(node, name))
473
- for found: Array in moved:
474
- var node: Node = found[0]
475
- var before: Dictionary = _before[node.get_instance_id()]
476
- for name: String in found[1]:
477
- node.set(name, before.get(name, HeadlessCheck.default_of(node, name)))
478
- var again: PackedScene = PackedScene.new()
479
- var packing: Error = again.pack(root)
480
- if packing != OK:
481
- return [null, "Failed to pack %s: %s" % [path, error_string(packing)]]
482
- var left: Array[Array] = _strays(again.get_state())
483
- left.append_array(_moved_by_scripts())
484
- if left.is_empty():
485
- return [again, ""]
523
+ var current: PackedScene = packed
524
+ var left: Array[Array] = []
525
+ for put_back: int in PUTTING_BACK_PASSES + 1:
526
+ var stray: Array[Array] = _strays(current.get_state())
527
+ var moved: Array[Array] = _moved_by_scripts()
528
+ if stray.is_empty() and moved.is_empty():
529
+ return [current, ""]
530
+ if put_back == PUTTING_BACK_PASSES:
531
+ left = stray
532
+ left.append_array(moved)
533
+ break
534
+ for found: Array in stray:
535
+ var node: Node = found[0]
536
+ var name: String = found[1]
537
+ node.set(name, found[3] if found[2] else _unset_value(node, name))
538
+ for found: Array in moved:
539
+ var node: Node = found[0]
540
+ var before: Dictionary = _before[node.get_instance_id()]
541
+ for name: String in found[1]:
542
+ node.set(name, before.get(name, HeadlessCheck.default_of(node, name)))
543
+ current = PackedScene.new()
544
+ var packing: Error = current.pack(root)
545
+ if packing != OK:
546
+ return [null, "Failed to pack %s: %s" % [path, error_string(packing)]]
486
547
  var first: Array = left[0]
487
548
  var stray_node: Node = first[0]
488
549
  var named: String = str(first[1])
@@ -538,6 +599,8 @@ func _strays(state: SceneState) -> Array[Array]:
538
599
  var held: bool = filed.has(name)
539
600
  if held == stored.has(name) and (not held or PropertyValues.same(stored[name], filed[name])):
540
601
  continue
602
+ if not held and HeadlessCheck.at_default(node, name):
603
+ continue
541
604
  stray.append([node, name, held, filed.get(name)])
542
605
  return stray
543
606
 
@@ -194,23 +194,36 @@ func _unkept_headless(resource: Resource, path: String, names: Array, previous:
194
194
  func create_resource(args: Dictionary) -> Dictionary:
195
195
  var res_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
196
196
  var resource_type: String = str(args.get("resourceType", "Resource"))
197
+ var asked_type: String = resource_type
197
198
  if res_path == "res://":
198
199
  return {"ok": false, "error": "resourcePath is required"}
199
200
  var taken: String = _taken(res_path, "resource_edit op=create")
200
201
  if not taken.is_empty():
201
202
  return {"ok": false, "error": taken}
202
203
 
203
- if (
204
- not ClassDB.class_exists(resource_type)
205
- or not ClassDB.is_parent_class(resource_type, "Resource")
206
- or not ClassDB.can_instantiate(resource_type)
207
- ):
204
+ var script_path: String = str(args.get("script", ""))
205
+ var made: Dictionary = SceneFile.made_as(
206
+ resource_type, "" if script_path == "" else _ensure_res_path(script_path), "Resource", "resourceType"
207
+ )
208
+ if made.has("error"):
209
+ return {"ok": false, "error": made["error"]}
210
+ resource_type = str(made["type"])
211
+ script_path = str(made["script"])
212
+ if not ClassDB.class_exists(resource_type):
213
+ return {
214
+ "ok": false,
215
+ "error":
216
+ (
217
+ "%s is neither an engine class nor a class_name a script in this project declares."
218
+ % resource_type
219
+ )
220
+ }
221
+ if not ClassDB.is_parent_class(resource_type, "Resource") or not ClassDB.can_instantiate(resource_type):
208
222
  return {"ok": false, "error": "%s is not a resource class that can be created." % resource_type}
209
223
  var resource: Resource = ClassDB.instantiate(resource_type)
210
224
 
211
225
  # A script that did not load was skipped, and a plain resource was saved in place of the custom
212
226
  # one asked for.
213
- var script_path: String = str(args.get("script", ""))
214
227
  if script_path != "":
215
228
  var script_file: String = _ensure_res_path(script_path)
216
229
  var script: Script = load(script_file) as Script
@@ -238,7 +251,7 @@ func create_resource(args: Dictionary) -> Dictionary:
238
251
  var failed: String = _save_checked(resource, res_path, properties.keys(), null)
239
252
  if not failed.is_empty():
240
253
  return {"ok": false, "error": failed}
241
- return {"ok": true, "resourcePath": res_path, "resourceType": resource.get_class()}
254
+ return {"ok": true, "resourcePath": res_path, "resourceType": asked_type}
242
255
 
243
256
 
244
257
  func modify_resource(args: Dictionary) -> Dictionary:
@@ -186,6 +186,17 @@ func create_scene(args: Dictionary) -> Dictionary:
186
186
  return {"ok": false, "error": "Missing scenePath"}
187
187
  if not scene_path.ends_with(".tscn"):
188
188
  scene_path += ".tscn"
189
+ var asked_type: String = root_node_type
190
+ var made: Dictionary = SceneFile.made_as(
191
+ root_node_type,
192
+ "" if script_path.is_empty() else _to_scene_res_path(project_path, script_path),
193
+ "Node",
194
+ "rootNodeType"
195
+ )
196
+ if made.has("error"):
197
+ return {"ok": false, "error": made["error"]}
198
+ root_node_type = str(made["type"])
199
+ script_path = str(made["script"])
189
200
  # A class that is not a node went into a Node variable, which is a runtime error the caller
190
201
  # heard about only as "Invalid tool result".
191
202
  if not ClassDB.class_exists(root_node_type) or not ClassDB.is_parent_class(root_node_type, "Node"):
@@ -237,6 +248,9 @@ func create_scene(args: Dictionary) -> Dictionary:
237
248
  "%s extends %s, so it cannot be on a %s root." % [full_script_path, base, root_node_type]
238
249
  }
239
250
  root.set_script(script)
251
+ # Named again, since a headless engine runs the script's _init as it is attached, and a node's
252
+ # name is no property the write compares.
253
+ root.name = root_node_name
240
254
 
241
255
  var folder: String = SceneFile.make_folder_for(scene_path)
242
256
  if not folder.is_empty():
@@ -246,9 +260,7 @@ func create_scene(args: Dictionary) -> Dictionary:
246
260
  if not written.is_empty():
247
261
  return written
248
262
 
249
- return {
250
- "ok": true, "scenePath": scene_path, "rootNodeType": root_node_type, "rootNodeName": root_node_name
251
- }
263
+ return {"ok": true, "scenePath": scene_path, "rootNodeType": asked_type, "rootNodeName": root_node_name}
252
264
 
253
265
 
254
266
  func list_scene_nodes(args: Dictionary) -> Dictionary:
@@ -276,6 +288,16 @@ func add_node(args: Dictionary) -> Dictionary:
276
288
 
277
289
  if node_name.is_empty():
278
290
  return {"ok": false, "error": "Missing nodeName"}
291
+ var asked_type: String = node_type
292
+ var class_script: Script = null
293
+ if not node_type.is_empty() and not ClassDB.class_exists(node_type):
294
+ var declared: Dictionary = SceneFile.script_class(node_type, "Node")
295
+ if declared.has("error"):
296
+ return {"ok": false, "error": declared["error"]}
297
+ var held: Variant = declared.get("script")
298
+ if held is Script:
299
+ class_script = held
300
+ node_type = str(declared["base"])
279
301
  var making: Array = Placing.what_to_add(node_type, instance_path, scene_path)
280
302
  var packed: PackedScene = making[0]
281
303
  if not str(making[1]).is_empty():
@@ -304,6 +326,9 @@ func add_node(args: Dictionary) -> Dictionary:
304
326
 
305
327
  # In the tree before its properties, so a property holding a node can find it by path.
306
328
  var new_node: Node = Placing.made(packed, node_type)
329
+ if class_script != null:
330
+ scene.asked(new_node, ["script"])
331
+ new_node.set_script(class_script)
307
332
  new_node.name = node_name
308
333
  parent.add_child(new_node)
309
334
  Placing.place(new_node, place)
@@ -321,12 +346,15 @@ func add_node(args: Dictionary) -> Dictionary:
321
346
  var answer: Dictionary = {
322
347
  "ok": true,
323
348
  "nodeName": node_name,
324
- "nodeType": new_node.get_class(),
349
+ "nodeType": asked_type if class_script != null else new_node.get_class(),
325
350
  "nodePath": new_path,
326
351
  "index": new_node.get_index(),
327
352
  }
328
353
  var written: Dictionary = scene.write(
329
- func(saved: Node) -> String: return _kept(scene, saved, new_node, properties.keys())
354
+ func(saved: Node) -> String:
355
+ return _kept(
356
+ scene, saved, new_node, properties.keys() + (["script"] if class_script != null else [])
357
+ )
330
358
  )
331
359
  if not written.is_empty():
332
360
  return written
@@ -342,7 +370,14 @@ func delete_node(args: Dictionary) -> Dictionary:
342
370
  var node_path: String = str(args.get("nodePath", ""))
343
371
 
344
372
  if node_path.is_empty() or node_path == ".":
345
- return {"ok": false, "error": "Cannot delete root node"}
373
+ return {
374
+ "ok": false,
375
+ "error":
376
+ (
377
+ "The root cannot be deleted, since a scene has exactly one. Delete the nodes under it, or "
378
+ + "make a scene with another root with scene_create."
379
+ )
380
+ }
346
381
 
347
382
  var opened: Array = _open(scene_path, true)
348
383
  var scene: SceneFile = opened[0]
@@ -480,6 +515,9 @@ func reparent_node(args: Dictionary) -> Dictionary:
480
515
  return scene.refuse(unplaceable)
481
516
 
482
517
  var old_path: String = scene.path_of(node)
518
+ # Unowned while it moves: a node taken out of the tree keeps its owner, and adding it under the new
519
+ # parent warned that the owner was not above it. Node.reparent warns the same way.
520
+ node.owner = null
483
521
  node.get_parent().remove_child(node)
484
522
  new_parent.add_child(node)
485
523
  Placing.place(node, place)
@@ -352,11 +352,14 @@ func _process(_delta: float) -> void:
352
352
  _queries.note_frame(Time.get_ticks_usec())
353
353
  _calibrate_the_profiler()
354
354
 
355
- if _server.is_connection_available():
355
+ # Every connection waiting rather than one a frame: the engine queues eight a game has not
356
+ # taken and refuses the rest, so calls sent together would be refused as if the game were not up.
357
+ while _server.is_connection_available():
356
358
  var client: StreamPeerTCP = _server.take_connection()
357
- if client:
358
- _clients.append(client)
359
- _send_welcome(client)
359
+ if client == null:
360
+ break
361
+ _clients.append(client)
362
+ _send_welcome(client)
360
363
 
361
364
  var gone: Array[StreamPeerTCP] = []
362
365
  for client: StreamPeerTCP in _clients:
@@ -343,6 +343,16 @@ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false
343
343
  var screenshot_path: String = requested_path
344
344
  if screenshot_path.begins_with("user://") or screenshot_path.begins_with("res://"):
345
345
  screenshot_path = ProjectSettings.globalize_path(screenshot_path)
346
+ # The server makes the folder for each capture and takes it away when it stops waiting for one,
347
+ # and a server replaced since the capture was asked for took it with it. Saving into a folder that
348
+ # is gone raises an engine error, which counts against a game that did nothing wrong.
349
+ if not DirAccess.dir_exists_absolute(screenshot_path.get_base_dir()):
350
+ return _refused(
351
+ (
352
+ "The folder to save the capture in, %s, is gone: the server that asked for it stopped waiting"
353
+ % screenshot_path.get_base_dir()
354
+ )
355
+ )
346
356
  var save_error: Error = image.save_png(screenshot_path)
347
357
  if save_error != OK:
348
358
  return {"type": "error", "message": "Failed to save screenshot as PNG: " + str(save_error)}
@@ -164,8 +164,8 @@ func _argument_for(
164
164
  "type": "error",
165
165
  "message":
166
166
  (
167
- "%s.%s takes %s as argument %d and was given %s, which it would have to be cut to fit"
168
- % [called, named, type_string(wants), index + 1, JSON.stringify(sent)]
167
+ "%s.%s takes %s as argument %d and was given %s, which %s cannot hold without changing it"
168
+ % [called, named, type_string(wants), index + 1, JSON.stringify(sent), Values.cut_by(wants)]
169
169
  )
170
170
  }
171
171
  var rounded: String = Values.inexact(sent, wants)
@@ -259,8 +259,8 @@ func _value_for(
259
259
  "type": "error",
260
260
  "message":
261
261
  (
262
- "%s.%s holds %s and the value given is %s, which it would have to be cut to fit"
263
- % [called, named, type_string(wanted), JSON.stringify(value)]
262
+ "%s.%s holds %s and the value given is %s, which %s cannot hold without changing it"
263
+ % [called, named, type_string(wanted), JSON.stringify(value), Values.cut_by(wanted)]
264
264
  )
265
265
  }
266
266
  if declared != TYPE_NIL or (not cut and Values.acceptable(as_sent, wanted)):
@@ -563,6 +563,10 @@ static func _walk_from(object: Object, path: String) -> Dictionary:
563
563
  ## null, and the method list says how many a method takes: its parameters less those with defaults,
564
564
  ## up to all of them, or any number past that for one taking varargs.
565
565
  static func _argument_count_refused(holder: Object, method: String, given: int) -> String:
566
+ # A node's method list runs to hundreds of entries, and walking it costs a couple of milliseconds
567
+ # of the game's frame; a call giving exactly the arguments the method declares needs no walk.
568
+ if given == holder.get_method_argument_count(method):
569
+ return ""
566
570
  for entry: Dictionary in holder.get_method_list():
567
571
  if str(entry.get("name", "")) != method:
568
572
  continue
@@ -156,6 +156,11 @@ static func cuts(given: Variant, type: int) -> bool:
156
156
  return not (whole and (value == 0.0 or value == 1.0))
157
157
 
158
158
 
159
+ ## The slot [method cuts] speaks of, with its article, for a refusal to name: "an int" or "a bool".
160
+ static func cut_by(type: int) -> String:
161
+ return "an int" if type == TYPE_INT else "a bool"
162
+
163
+
159
164
  ## Why [param given] may not be the whole number sent, or "": see [method Serialisation.inexact].
160
165
  static func inexact(given: Variant, type: int) -> String:
161
166
  return Serialisation.inexact(given, type)
@@ -411,10 +411,10 @@ static func _cut_to_fit(given: Variant, type: int, node_path: String, property:
411
411
  return ""
412
412
  return (
413
413
  (
414
- "%s.%s holds %s and the value to wait for is %s, which it would have to be cut to fit:"
414
+ "%s.%s holds %s and the value to wait for is %s, which %s cannot hold without changing it:"
415
415
  + " waiting on it would answer about a state nobody asked for."
416
416
  )
417
- % [node_path, property, type_string(type), str(given)]
417
+ % [node_path, property, type_string(type), str(given), Values.cut_by(type)]
418
418
  )
419
419
 
420
420
 
@@ -46,10 +46,17 @@ const BEGUN: String = "[gdharness] begun"
46
46
 
47
47
  ## The operations that compile the project's scripts. The engine knows an autoload's name only once
48
48
  ## the autoload is in the tree, which is after `_init`, so a script naming one compiled here failed
49
- ## with "Identifier not found" while the game and the editor ran it. A scene tool loads the scripts
50
- ## of the scene it opens.
49
+ ## with "Identifier not found" while the game and the editor ran it. A scene tool, and a read of a
50
+ ## scene or a resource, loads the scripts of the file it opens.
51
51
  const AFTER_THE_AUTOLOADS: Array[String] = [
52
- "check_scripts", "create_script", "modify_script", "run_editor_tool"
52
+ "check_scripts",
53
+ "create_script",
54
+ "modify_script",
55
+ "read_animation_states",
56
+ "read_animations",
57
+ "read_resource",
58
+ "read_scene_tree",
59
+ "run_editor_tool",
53
60
  ]
54
61
 
55
62
  var _log: Log