gdharness 1.2.35 → 1.2.37

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.
Files changed (28) hide show
  1. package/build/cli.js +478 -100
  2. package/build/godot/addons/gdharness_editor/headless_check.gd +374 -0
  3. package/build/godot/addons/gdharness_editor/headless_check.gd.uid +1 -0
  4. package/build/godot/addons/gdharness_editor/property_values.gd +3 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +345 -10
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +103 -5
  7. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -18
  8. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +24 -13
  9. package/build/godot/addons/gdharness_runtime/call_errors.gd +7 -2
  10. package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +9 -5
  11. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +33 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +21 -4
  13. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +7 -5
  14. package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +116 -3
  16. package/build/godot/addons/gdharness_runtime/serialisation.gd +103 -5
  17. package/build/godot/operations/classdb_queries.gd +22 -0
  18. package/build/godot/operations/dependencies.gd +90 -20
  19. package/build/godot/operations/editor_tools.gd +57 -0
  20. package/build/godot/operations/gdscript_analysis.gd +45 -2
  21. package/build/godot/operations/gdscript_authoring.gd +5 -2
  22. package/build/godot/operations/godot_operations.gd +53 -2
  23. package/build/godot/operations/property_values.gd +3 -0
  24. package/build/godot/operations/rename_checks.gd +0 -19
  25. package/build/godot/operations/script_check.gd +98 -0
  26. package/build/godot/operations/serialisation.gd +103 -5
  27. package/build/index.js +466 -90
  28. package/package.json +1 -1
@@ -16,7 +16,16 @@ extends RefCounted
16
16
  ## - [code]pack[/code] keeps only what the scene itself owns, so a property set on a node inside an
17
17
  ## instance, a node deleted from one and a node moved out of one were answered as done and were
18
18
  ## not in the file.
19
-
19
+ ##
20
+ ## A headless engine writes for a project no editor serves, and there one more thing differs: the
21
+ ## editor gives a script that is not a tool a placeholder, and anywhere else instancing a scene runs
22
+ ## every script in it. Measured on 4.7.2, an [code]_init[/code] setting modulate and an exported
23
+ ## setter setting scale were both saved into the file by a write to another node, and a root whose
24
+ ## script failed in [code]_init[/code] was saved without it. Outside the editor, then, what a node
25
+ ## stores is put back to what the file holds wherever the tool did not ask for a change, before
26
+ ## anything is saved, and a write is refused when that does not hold. See [HeadlessCheck].
27
+
28
+ const HeadlessCheck = preload("headless_check.gd")
20
29
  const PropertyValues = preload("property_values.gd")
21
30
 
22
31
  ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
@@ -27,6 +36,19 @@ var root: Node = null
27
36
  var path: String = ""
28
37
  var _base_paths: Dictionary = {}
29
38
  var _state: SceneState = null
39
+ ## What the file stores on each node it was opened with, by the node's instance id, so a node keeps
40
+ ## its record when a tool moves or renames it. Only kept outside the editor.
41
+ var _filed: Dictionary = {}
42
+ ## The properties a tool set on each node, by instance id: the differences it asked for.
43
+ var _asked: Dictionary = {}
44
+ ## What each node a tool set properties on held before it did, by instance id.
45
+ var _before: Dictionary = {}
46
+ ## Each copy a tool made, by instance id, to the instance id of the node it copies.
47
+ var _copied_from: Dictionary = {}
48
+ ## The embedded resources a tool reached into through an indexed property, by their id in the file.
49
+ var _reached: Dictionary = {}
50
+ ## The file as it was opened, to compare its embedded resources with what the write saves.
51
+ var _text: String = ""
30
52
 
31
53
 
32
54
  ## Opens [param scene_path], answering a refusal, or {} with [member root] set.
@@ -56,12 +78,59 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
56
78
  for index: int in base.get_node_count():
57
79
  _base_paths[_relative(str(base.get_node_path(index)))] = true
58
80
  base = base.get_base_scene_state()
81
+ # On a read as well, which would otherwise answer what the scripts set as what the file holds.
82
+ if not Engine.is_editor_hint():
83
+ if for_writing:
84
+ _text = FileAccess.get_file_as_string(path)
85
+ for index: int in _state.get_node_count():
86
+ var node: Node = _node_at(str(_state.get_node_path(index)))
87
+ if node != null:
88
+ _filed[node.get_instance_id()] = _stored_at(_state, index)
89
+ var unsettled: String = settle()
90
+ if not unsettled.is_empty():
91
+ return refuse(("Nothing was written: " if for_writing else "Not read: ") + unsettled)
59
92
  return {}
60
93
 
61
94
 
95
+ ## Puts back what the scene's scripts changed on every node a tool has not asked to change, as
96
+ ## [method write] does, answering "" or why that did not hold. Called once a node has come into the
97
+ ## scene, so what its scripts did on the way in is not taken for the tool's own change.
98
+ func settle() -> String:
99
+ if Engine.is_editor_hint():
100
+ return ""
101
+ var packed: PackedScene = PackedScene.new()
102
+ var packing: Error = packed.pack(root)
103
+ if packing != OK:
104
+ return "Failed to pack %s: %s" % [path, error_string(packing)]
105
+ return _settled(packed)[1]
106
+
107
+
108
+ ## Takes [param names] on [param node] as the tool's own changes, which a write saves as they are.
109
+ ## Called before the tool sets them.
110
+ func asked(node: Node, names: Array) -> void:
111
+ var id: int = node.get_instance_id()
112
+ var on: Dictionary = _asked.get_or_add(id, {})
113
+ if not _before.has(id):
114
+ _before[id] = HeadlessCheck.stored(node)
115
+ for name: Variant in names:
116
+ var property: String = str(name)
117
+ on[property] = true
118
+ on[property.get_slice(":", 0)] = true
119
+ _reached.merge(HeadlessCheck.reached(node, property))
120
+
121
+
122
+ ## Takes each copy in [param copies], keyed by the node it was made from, as holding what that node's
123
+ ## file entry holds.
124
+ func copied(copies: Dictionary) -> void:
125
+ for source: Variant in copies:
126
+ var original: Node = source
127
+ var copy: Node = copies[source]
128
+ _copied_from[copy.get_instance_id()] = original.get_instance_id()
129
+
130
+
62
131
  ## Why [param scene_path] cannot be written while the editor has it open, or "".
63
132
  static func unsaved_in_editor(scene_path: String) -> String:
64
- if not EditorInterface.get_unsaved_scenes().has(scene_path):
133
+ if not Engine.is_editor_hint() or not EditorInterface.get_unsaved_scenes().has(scene_path):
65
134
  return ""
66
135
  return (
67
136
  (
@@ -142,16 +211,25 @@ static func make_folder_for(file_path: String) -> String:
142
211
  ## Why [param script], loaded from [param script_path], cannot be attached because the editor could not
143
212
  ## compile it, or "". A script that did not compile has no base type, and the refusal built from that
144
213
  ## said "start.gd extends , so it cannot be on a Control root" about a script that was fine, whose
145
- ## failure was a class it called being behind in the editor.
214
+ ## failure was a class it called being behind in the editor. A headless engine compiles every
215
+ ## script from its file, so only an editor can hold a copy that is behind.
146
216
  static func uncompiled(script: Script, script_path: String) -> String:
147
217
  if not str(script.get_instance_base_type()).is_empty():
148
218
  return ""
219
+ if not Engine.is_editor_hint():
220
+ return (
221
+ (
222
+ "%s does not compile, so it cannot be attached. script_diagnostics on it gives the "
223
+ + "parser's errors."
224
+ )
225
+ % script_path
226
+ )
149
227
  return (
150
228
  (
151
- "%s does not compile in the editor, so it cannot be attached. script_diagnostics on it gives "
152
- + "the parser's errors. If they deny a member that another class's file declares, the "
153
- + "editor's copy of that class is behind: editor_rescan with reloadScript on that class, then "
154
- + "ask again."
229
+ "%s does not compile in the editor, so it cannot be attached. script_diagnostics on it "
230
+ + "gives the parser's errors. If they deny a member that another class's file declares, "
231
+ + "the editor's copy of that class is behind: editor_rescan with reloadScript on that "
232
+ + "class, then ask again."
155
233
  )
156
234
  % script_path
157
235
  )
@@ -221,6 +299,26 @@ func write(check: Callable = Callable()) -> Dictionary:
221
299
  var packing: Error = packed.pack(root)
222
300
  if packing != OK:
223
301
  return refuse("Failed to pack %s: %s" % [path, error_string(packing)])
302
+ var headless: bool = not Engine.is_editor_hint()
303
+ if headless:
304
+ var settled: Array = _settled(packed)
305
+ if not str(settled[1]).is_empty():
306
+ return refuse("Nothing was written: " + str(settled[1]))
307
+ packed = settled[0]
308
+ if not path.ends_with(".tscn") and _holds_scripted_embedded():
309
+ return refuse(
310
+ (
311
+ (
312
+ (
313
+ "Nothing was written: %s is a binary scene holding an embedded resource with a script "
314
+ + "that is not a tool, and what that script changed when a headless engine loaded it "
315
+ + "cannot be told apart from the file here. "
316
+ )
317
+ % path
318
+ )
319
+ + HeadlessCheck.REMEDY
320
+ )
321
+ )
224
322
  if check.is_valid():
225
323
  var saved: Node = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
226
324
  if saved == null:
@@ -230,7 +328,33 @@ func write(check: Callable = Callable()) -> Dictionary:
230
328
  if not missing.is_empty():
231
329
  return refuse("Nothing was written: " + missing)
232
330
  close()
233
- return save_packed(packed, path)
331
+ var previous: Variant = null
332
+ if FileAccess.file_exists(path):
333
+ previous = FileAccess.get_file_as_bytes(path)
334
+ var written: Dictionary = save_packed(packed, path)
335
+ if not written.is_empty() or not headless or not path.ends_with(".tscn"):
336
+ return written
337
+ var state: SceneState = packed.get_state()
338
+ var values: Array = []
339
+ for index: int in state.get_node_count():
340
+ values.append_array(_stored_at(state, index).values())
341
+ var unkept: String = HeadlessCheck.settle_saved(
342
+ path, _text, values, null, {}, _reached, func() -> Error: return save_keeping_uid(packed, path)
343
+ )
344
+ if unkept.is_empty():
345
+ return {}
346
+ return {"ok": false, "error": "Nothing was written: " + unkept + HeadlessCheck.put_back(path, previous)}
347
+
348
+
349
+ func _holds_scripted_embedded() -> bool:
350
+ var seen: Dictionary = {}
351
+ var nodes: Array[Node] = [root]
352
+ while not nodes.is_empty():
353
+ var node: Node = nodes.pop_back()
354
+ if HeadlessCheck.holds_scripted_embedded(node, seen):
355
+ return true
356
+ nodes.append_array(node.get_children())
357
+ return false
234
358
 
235
359
 
236
360
  ## Writes [param packed] to [param scene_path], making its directory, and brings the editor up to
@@ -241,18 +365,51 @@ static func save_packed(packed: PackedScene, scene_path: String) -> Dictionary:
241
365
  var made: Error = DirAccess.make_dir_recursive_absolute(directory)
242
366
  if made != OK:
243
367
  return {"ok": false, "error": "Could not create %s: %s" % [directory, error_string(made)]}
244
- var saving: Error = ResourceSaver.save(packed, scene_path)
368
+ var saving: Error = save_keeping_uid(packed, scene_path)
245
369
  if saving != OK:
246
370
  return {"ok": false, "error": "Failed to save %s: %s" % [scene_path, error_string(saving)]}
247
371
  refresh(scene_path)
248
372
  return {}
249
373
 
250
374
 
375
+ ## Saves [param resource] at [param file_path], keeping the uid the file already has.
376
+ ##
377
+ ## The editor saves through its uid cache, which knows every file's. A headless engine that has not
378
+ ## imported the project has none, and a resave dropped the uid from the header, so every reference
379
+ ## made through it stopped resolving. Read from the file itself, since the loader does not answer it
380
+ ## for a text scene there, and a new file is given one as the editor gives it.
381
+ static func save_keeping_uid(resource: Resource, file_path: String) -> Error:
382
+ if Engine.is_editor_hint():
383
+ return ResourceSaver.save(resource, file_path)
384
+ var uid: int = _uid_in(file_path)
385
+ var saving: Error = ResourceSaver.save(resource, file_path)
386
+ if saving != OK:
387
+ return saving
388
+ if uid == ResourceUID.INVALID_ID:
389
+ uid = ResourceUID.create_id()
390
+ return ResourceSaver.set_uid(file_path, uid)
391
+
392
+
393
+ ## The uid the file at [param file_path] declares, or [constant ResourceUID.INVALID_ID].
394
+ static func _uid_in(file_path: String) -> int:
395
+ if not FileAccess.file_exists(file_path):
396
+ return ResourceUID.INVALID_ID
397
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
398
+ if file != null:
399
+ var header: RegExMatch = RegEx.create_from_string('uid="(uid://[a-z0-9]+)"').search(file.get_line())
400
+ file.close()
401
+ if header != null:
402
+ return ResourceUID.text_to_id(header.get_string(1))
403
+ return ResourceLoader.get_resource_uid(file_path)
404
+
405
+
251
406
  ## Brings the editor up to date with a scene file written under it: the cached copy anything holds,
252
407
  ## the editor's file list, and the tab showing it, whether current or not.
253
408
  static func refresh(scene_path: String) -> void:
254
409
  if ResourceLoader.has_cached(scene_path):
255
410
  var _replaced: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_REPLACE)
411
+ if not Engine.is_editor_hint():
412
+ return
256
413
  scan_later()
257
414
  if EditorInterface.get_open_scenes().has(scene_path):
258
415
  EditorInterface.reload_scene_from_path(scene_path)
@@ -294,7 +451,185 @@ static func _start_scan() -> void:
294
451
  ## Whether the editor holds unsaved changes to [param scene_path], for a read to say that the file
295
452
  ## it answers from is not what the editor shows.
296
453
  static func unsaved(scene_path: String) -> bool:
297
- return EditorInterface.get_unsaved_scenes().has(scene_path)
454
+ return Engine.is_editor_hint() and EditorInterface.get_unsaved_scenes().has(scene_path)
455
+
456
+
457
+ ## [param packed] with what the scene's scripts changed put back, as [pack, ""], or [null, why not].
458
+ ##
459
+ ## On a node the tool did not ask to change, a difference is put back to what the file holds, or, for
460
+ ## a property the file does not set, to what the node would load with without it. On a node it did
461
+ ## 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.
464
+ 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, ""]
486
+ var first: Array = left[0]
487
+ var stray_node: Node = first[0]
488
+ var named: String = str(first[1])
489
+ if first[1] is Array:
490
+ var names: Array = first[1]
491
+ named = str(names[0])
492
+ return [null, _stray_text(stray_node, named)]
493
+
494
+
495
+ ## Each node the tool asked to change through a script that is not a tool, with the properties it did
496
+ ## not name that no longer hold what they held before, as [node, names].
497
+ func _moved_by_scripts() -> Array[Array]:
498
+ var moved: Array[Array] = []
499
+ for key: Variant in _asked:
500
+ var id: int = key
501
+ var node: Node = instance_from_id(id) as Node
502
+ if node == null or not root.is_ancestor_of(node) and node != root:
503
+ continue
504
+ var on: Dictionary = _asked[id]
505
+ if not HeadlessCheck.through_script(node, on):
506
+ continue
507
+ var before: Dictionary = _before[id]
508
+ var changed: Array[String] = HeadlessCheck.differing(node, before, on)
509
+ if not changed.is_empty():
510
+ moved.append([node, changed])
511
+ return moved
512
+
513
+
514
+ ## Every property [param state] stores differently from the file, on a node the tool did not ask to
515
+ ## change it on, as [node, name, whether the file stores it, the file's value].
516
+ func _strays(state: SceneState) -> Array[Array]:
517
+ var stray: Array[Array] = []
518
+ for index: int in state.get_node_count():
519
+ var node: Node = _node_at(str(state.get_node_path(index)))
520
+ if node == null:
521
+ continue
522
+ var id: int = node.get_instance_id()
523
+ if _asked.has(id):
524
+ continue
525
+ var filed: Dictionary = _filed.get(id, _filed.get(_copied_from.get(id, 0), {}))
526
+ # A node is instanced before it is added to its parent, which is before its children are, so
527
+ # what a script does as a scene loads stays on the script's own node. Anything else a pack
528
+ # changes is Godot's doing and the editor's pack does it too: a Control saved without its
529
+ # grow directions gets them back from anchors_preset's setter, and loses an anchors_preset
530
+ # its layout mode does not store.
531
+ if not HeadlessCheck.untooled(node.get_script()) and not HeadlessCheck.untooled(filed.get("script")):
532
+ continue
533
+ var stored: Dictionary = _stored_at(state, index)
534
+ var names: Dictionary = filed.duplicate()
535
+ names.merge(stored)
536
+ for key: Variant in names:
537
+ var name: String = str(key)
538
+ var held: bool = filed.has(name)
539
+ if held == stored.has(name) and (not held or PropertyValues.same(stored[name], filed[name])):
540
+ continue
541
+ stray.append([node, name, held, filed.get(name)])
542
+ return stray
543
+
544
+
545
+ func _stray_text(node: Node, name: String) -> String:
546
+ var at: String = str(root.name) if node == root else path_of(node)
547
+ if name == "script":
548
+ return (
549
+ (
550
+ (
551
+ "the script of %s did not start when a headless engine instanced %s, so the node would be "
552
+ + "saved without it; the engine's messages give its errors. "
553
+ )
554
+ % [at, path]
555
+ )
556
+ + HeadlessCheck.REMEDY
557
+ )
558
+ return (
559
+ (
560
+ (
561
+ "%s.%s would be saved differently from what %s holds, and the call did not ask for it: the "
562
+ + "scene's scripts ran when a headless engine instanced it, as they do in the game and not in "
563
+ + "an editor. "
564
+ )
565
+ % [at, name, path]
566
+ )
567
+ + HeadlessCheck.REMEDY
568
+ )
569
+
570
+
571
+ ## What [param node] would load with for [param name] if this file did not set it: what the scenes
572
+ ## it comes from store, followed down through every scene instanced or inherited, else its script's
573
+ ## or class's default. Read from their files, since instancing them would run their scripts.
574
+ func _unset_value(node: Node, name: String) -> Variant:
575
+ var base: SceneState = _state.get_base_scene_state() if _state != null else null
576
+ if node == root or _base_paths.has(path_of(node)):
577
+ return _value_in(base, path_of(node), node, name)
578
+ var top: Node = node
579
+ while top != root and top.owner != root:
580
+ top = top.get_parent()
581
+ if top == root or top.scene_file_path.is_empty():
582
+ return HeadlessCheck.default_of(node, name)
583
+ if _base_paths.has(path_of(top)):
584
+ return _value_in(base, path_of(node), node, name)
585
+ var instanced: PackedScene = ResourceLoader.load(top.scene_file_path) as PackedScene
586
+ return _value_in(
587
+ instanced.get_state() if instanced != null else null, str(top.get_path_to(node)), node, name
588
+ )
589
+
590
+
591
+ ## What [param state] gives the node at [param at] for [param name], following it into the scene an
592
+ ## enclosing node instances, or into the base scene, until a file sets it.
593
+ func _value_in(state: SceneState, at: String, node: Node, name: String) -> Variant:
594
+ while state != null:
595
+ var inside: String = ""
596
+ var instanced: PackedScene = null
597
+ for index: int in state.get_node_count():
598
+ var here: String = _relative(str(state.get_node_path(index)))
599
+ if here == at:
600
+ for property: int in state.get_node_property_count(index):
601
+ if str(state.get_node_property_name(index, property)) == name:
602
+ return state.get_node_property_value(index, property)
603
+ var scene: PackedScene = state.get_node_instance(index)
604
+ if scene == null:
605
+ continue
606
+ var encloses: bool = here == at or here == "." or at.begins_with(here + "/")
607
+ if encloses and (instanced == null or here.length() > inside.length() or inside == "."):
608
+ inside = here
609
+ instanced = scene
610
+ if instanced != null:
611
+ if inside == at:
612
+ at = "."
613
+ elif inside != ".":
614
+ at = at.substr(inside.length() + 1)
615
+ state = instanced.get_state()
616
+ else:
617
+ state = state.get_base_scene_state()
618
+ return HeadlessCheck.default_of(node, name)
619
+
620
+
621
+ func _node_at(node_path: String) -> Node:
622
+ var at: String = _relative(node_path)
623
+ return root if at == "." else root.get_node_or_null(NodePath(at))
624
+
625
+
626
+ static func _stored_at(state: SceneState, index: int) -> Dictionary:
627
+ var stored: Dictionary = {}
628
+ for property: int in state.get_node_property_count(index):
629
+ stored[str(state.get_node_property_name(index, property))] = (state.get_node_property_value(
630
+ index, property
631
+ ))
632
+ return stored
298
633
 
299
634
 
300
635
  func _relative(node_path: String) -> String:
@@ -16,6 +16,10 @@ const Read = preload("reading.gd")
16
16
  const NUMBERS: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]
17
17
  const TEXTS: Array[int] = [TYPE_STRING, TYPE_STRING_NAME, TYPE_NODE_PATH]
18
18
 
19
+ # 2^53. A JSON number is a double, and every whole number below this one in size has a double of
20
+ # its own; from here on two or more share each double, so one arriving tells nobody which was sent.
21
+ const EXACT: int = 9007199254740992
22
+
19
23
  # The types that cross as a plain JSON list and have to be built back into themselves. A list is
20
24
  # what a caller can write by hand and what the serialiser answers with, and the parameter or
21
25
  # property being written is the only thing that knows which of these it wanted.
@@ -53,6 +57,8 @@ const LIST_TYPES: Array[int] = [
53
57
  # type it wanted.
54
58
  const SERIALISERS: Dictionary = {
55
59
  TYPE_NIL: "_serialize_nil",
60
+ TYPE_INT: "_serialize_int",
61
+ TYPE_PACKED_INT64_ARRAY: "_serialize_ints",
56
62
  TYPE_FLOAT: "_serialize_float",
57
63
  TYPE_PACKED_FLOAT32_ARRAY: "_serialize_floats",
58
64
  TYPE_PACKED_FLOAT64_ARRAY: "_serialize_floats",
@@ -109,6 +115,8 @@ const BUILDERS: Dictionary = {
109
115
  "NodePath": "_build_node_path",
110
116
  "PackedByteArray": "_build_bytes",
111
117
  "float": "_build_float",
118
+ "int": "_build_int",
119
+ "RID": "_build_rid",
112
120
  "Dictionary": "_build_dictionary",
113
121
  }
114
122
 
@@ -136,6 +144,8 @@ const REQUIRED: Dictionary = {
136
144
  "NodePath": [["path"]],
137
145
  "PackedByteArray": [["base64"]],
138
146
  "float": [["value"]],
147
+ "int": [["value"]],
148
+ "RID": [["id"]],
139
149
  "Dictionary": [["entries"]],
140
150
  }
141
151
 
@@ -243,9 +253,40 @@ func deserialize_value(value: Variant) -> Variant:
243
253
  return fields
244
254
  if tag == "Dictionary" and not pairs(fields["entries"]):
245
255
  return fields
256
+ # Digits only: the tag is there to carry what a number cannot, and {"value": 5} meant for some
257
+ # other shape is not to be read as 5.
258
+ if tag == "int" and not (fields["value"] is String and _exact_int(fields["value"]) != null):
259
+ return fields
260
+ if tag == "RID" and _exact_int(fields["id"]) == null:
261
+ return fields
246
262
  return call(builder, fields)
247
263
 
248
264
 
265
+ ## [param held] as the whole number it spells exactly, or null: an int, a JSON number that is whole
266
+ ## and no larger than a double holds exactly, the digits of one, or the int tag carrying them. A
267
+ ## fraction, or a number past 2^53 that arrived as a number and so may already be a neighbour of the
268
+ ## one meant, is no id.
269
+ func _exact_int(held: Variant) -> Variant:
270
+ if held is int:
271
+ return held
272
+ if held is float:
273
+ var number: float = held
274
+ if is_finite(number) and number == floorf(number) and absf(number) < EXACT:
275
+ return int(number)
276
+ return null
277
+ if held is String:
278
+ var digits: String = held
279
+ var parsed: int = digits.to_int()
280
+ if digits.is_valid_int() and str(parsed) == digits:
281
+ return parsed
282
+ return null
283
+ if held is Dictionary:
284
+ var tagged: Dictionary = held
285
+ if str(tagged.get("_type", "")) == "int" and tagged.has("value"):
286
+ return _exact_int(tagged["value"])
287
+ return null
288
+
289
+
249
290
  ## The components a path reads off [param value], by the names GDScript gives them, or none for a
250
291
  ## value that has none: a vector's axes, a colour's channels, a rectangle's corners and size, a
251
292
  ## transform's basis and origin. A value of one of these is a copy wherever it is read, so a write
@@ -393,6 +434,11 @@ func fitted(value: Variant, type: int) -> Variant:
393
434
  if not simple.has(type) or not simple.has(typeof(rebuilt)):
394
435
  return rebuilt
395
436
 
437
+ # Digits are read as digits, since read as JSON they become a double and one past 2^53 a
438
+ # neighbour of the number written.
439
+ if rebuilt is String and type == TYPE_INT and _exact_int(rebuilt) != null:
440
+ return _exact_int(rebuilt)
441
+
396
442
  if rebuilt is String and type != TYPE_STRING:
397
443
  var text: String = rebuilt
398
444
  var parsed: Variant = Read.json_or_null(text)
@@ -408,13 +454,33 @@ func fitted(value: Variant, type: int) -> Variant:
408
454
  return type_convert(rebuilt, type)
409
455
 
410
456
 
457
+ ## Why [param given], meant for an int, may not be the number that was sent, or "" when it is. A
458
+ ## JSON number is a double wherever it is read, so a whole number from 2^53 on arrives as the nearest
459
+ ## double to the one written: 9007199254740993 sent as a number arrives as 9007199254740992, and a
460
+ ## seed written that way would be stored as its neighbour with the answer saying it was written.
461
+ static func inexact(given: Variant, type: int) -> String:
462
+ if type != TYPE_INT or (typeof(given) != TYPE_FLOAT and typeof(given) != TYPE_INT):
463
+ return ""
464
+ var number: float = Read.as_float(given)
465
+ if not is_finite(number) or absf(number) < EXACT:
466
+ return ""
467
+ return (
468
+ (
469
+ "%s is 2^53 or more, where a JSON number holds a whole number only to the nearest double,"
470
+ + ' so it may not be the number sent: send the digits as {"_type": "int", "value": "..."}'
471
+ + ", the form a read answers with"
472
+ )
473
+ % String.num(number, 0)
474
+ )
475
+
476
+
411
477
  ## How a value of [param type] is written to be read as one, for a refusal to name, or empty for a
412
478
  ## type that is not built from fields. A refusal naming only the two types left a caller to find
413
479
  ## the shape in the description of another op.
414
480
  static func written_as(type: int) -> String:
415
481
  var tag: String = type_string(type)
416
482
  var alternatives: Array = REQUIRED.get(tag, [])
417
- if alternatives.is_empty() or tag == "float" or tag == "Dictionary":
483
+ if alternatives.is_empty() or tag in ["float", "int", "Dictionary"]:
418
484
  return ""
419
485
  var keys: Array = alternatives[0]
420
486
  var parts: Array[String] = []
@@ -530,10 +596,32 @@ func _serialize_node_path(value: NodePath) -> Dictionary:
530
596
  return {"path": str(value), "_type": "NodePath"}
531
597
 
532
598
 
533
- # An id rather than nothing, because two properties holding the same RID are worth telling apart
534
- # even though neither can be handed back to the engine.
599
+ # By its id, which is all an RID is from outside the server holding it, and which builds it back:
600
+ # a viewport's RID read off one call is what RenderingServer's render timing takes in the next.
535
601
  func _serialize_rid(value: RID) -> Dictionary:
536
- return {"id": value.get_id(), "_type": "RID"}
602
+ return {"id": _serialize_int(value.get_id()), "_type": "RID"}
603
+
604
+
605
+ # A whole number from 2^53 on as its digits, because JSON numbers are doubles on the server's side
606
+ # and on the engine's, and from there on neighbouring whole numbers share a double. A RefCounted's
607
+ # instance id is one, with its top bit set, and a resource uid another; read as numbers they came
608
+ # back as a neighbouring id, which named nothing or something else.
609
+ func _serialize_int(value: int) -> Variant:
610
+ if value > -EXACT and value < EXACT:
611
+ return value
612
+ return {"_type": "int", "value": str(value)}
613
+
614
+
615
+ # A packed list of 64-bit numbers stays the JSON list it is unless something in it is too large for
616
+ # a double to hold.
617
+ func _serialize_ints(value: PackedInt64Array) -> Variant:
618
+ for number: int in value:
619
+ if number <= -EXACT or number >= EXACT:
620
+ var items: Array = []
621
+ for each: int in value:
622
+ items.append(_serialize_int(each))
623
+ return items
624
+ return value
537
625
 
538
626
 
539
627
  # A bound method, said in the terms a caller could act on: what it is on, and what it is called.
@@ -672,7 +760,7 @@ func _describe_object(value: Object) -> Dictionary:
672
760
  # in a costume and reads as if the object had been asked and had nothing to say.
673
761
  if value.has_method("_to_string"):
674
762
  described["says"] = value.to_string()
675
- described["id"] = value.get_instance_id()
763
+ described["id"] = _serialize_int(value.get_instance_id())
676
764
  return described
677
765
 
678
766
 
@@ -808,6 +896,16 @@ func _build_float(fields: Dictionary) -> float:
808
896
  return Read.as_float(fields)
809
897
 
810
898
 
899
+ func _build_int(fields: Dictionary) -> int:
900
+ var exact: int = _exact_int(fields["value"])
901
+ return exact
902
+
903
+
904
+ func _build_rid(fields: Dictionary) -> RID:
905
+ var id: int = _exact_int(fields["id"])
906
+ return rid_from_int64(id)
907
+
908
+
811
909
  ## The entries of a dictionary whose keys are not all text. JSON carries every number as a float,
812
910
  ## and a dictionary keys 1.0 apart from 1, so a whole-number key is built as an int: the key a
813
911
  ## game uses far more often, and one JSON cannot tell from the float.