gdharness 1.2.36 → 1.2.38
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 +918 -439
- package/build/godot/addons/gdharness_editor/headless_check.gd +411 -0
- package/build/godot/addons/gdharness_editor/headless_check.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/property_values.gd +3 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +408 -10
- package/build/godot/addons/gdharness_editor/serialisation.gd +103 -5
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +88 -25
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +68 -19
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +9 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +3 -0
- package/build/godot/addons/gdharness_runtime/serialisation.gd +103 -5
- package/build/godot/operations/editor_tools.gd +57 -0
- package/build/godot/operations/gdscript_analysis.gd +26 -2
- package/build/godot/operations/gdscript_authoring.gd +5 -2
- package/build/godot/operations/godot_operations.gd +58 -1
- package/build/godot/operations/property_values.gd +3 -0
- package/build/godot/operations/serialisation.gd +103 -5
- package/build/index.js +838 -366
- package/package.json +1 -1
|
@@ -16,7 +16,19 @@ 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")
|
|
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
|
|
20
32
|
const PropertyValues = preload("property_values.gd")
|
|
21
33
|
|
|
22
34
|
## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
|
|
@@ -27,6 +39,19 @@ var root: Node = null
|
|
|
27
39
|
var path: String = ""
|
|
28
40
|
var _base_paths: Dictionary = {}
|
|
29
41
|
var _state: SceneState = null
|
|
42
|
+
## What the file stores on each node it was opened with, by the node's instance id, so a node keeps
|
|
43
|
+
## its record when a tool moves or renames it. Only kept outside the editor.
|
|
44
|
+
var _filed: Dictionary = {}
|
|
45
|
+
## The properties a tool set on each node, by instance id: the differences it asked for.
|
|
46
|
+
var _asked: Dictionary = {}
|
|
47
|
+
## What each node a tool set properties on held before it did, by instance id.
|
|
48
|
+
var _before: Dictionary = {}
|
|
49
|
+
## Each copy a tool made, by instance id, to the instance id of the node it copies.
|
|
50
|
+
var _copied_from: Dictionary = {}
|
|
51
|
+
## The embedded resources a tool reached into through an indexed property, by their id in the file.
|
|
52
|
+
var _reached: Dictionary = {}
|
|
53
|
+
## The file as it was opened, to compare its embedded resources with what the write saves.
|
|
54
|
+
var _text: String = ""
|
|
30
55
|
|
|
31
56
|
|
|
32
57
|
## Opens [param scene_path], answering a refusal, or {} with [member root] set.
|
|
@@ -56,12 +81,59 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
|
|
|
56
81
|
for index: int in base.get_node_count():
|
|
57
82
|
_base_paths[_relative(str(base.get_node_path(index)))] = true
|
|
58
83
|
base = base.get_base_scene_state()
|
|
84
|
+
# On a read as well, which would otherwise answer what the scripts set as what the file holds.
|
|
85
|
+
if not Engine.is_editor_hint():
|
|
86
|
+
if for_writing:
|
|
87
|
+
_text = FileAccess.get_file_as_string(path)
|
|
88
|
+
for index: int in _state.get_node_count():
|
|
89
|
+
var node: Node = _node_at(str(_state.get_node_path(index)))
|
|
90
|
+
if node != null:
|
|
91
|
+
_filed[node.get_instance_id()] = _stored_at(_state, index)
|
|
92
|
+
var unsettled: String = settle()
|
|
93
|
+
if not unsettled.is_empty():
|
|
94
|
+
return refuse(("Nothing was written: " if for_writing else "Not read: ") + unsettled)
|
|
59
95
|
return {}
|
|
60
96
|
|
|
61
97
|
|
|
98
|
+
## Puts back what the scene's scripts changed on every node a tool has not asked to change, as
|
|
99
|
+
## [method write] does, answering "" or why that did not hold. Called once a node has come into the
|
|
100
|
+
## scene, so what its scripts did on the way in is not taken for the tool's own change.
|
|
101
|
+
func settle() -> String:
|
|
102
|
+
if Engine.is_editor_hint():
|
|
103
|
+
return ""
|
|
104
|
+
var packed: PackedScene = PackedScene.new()
|
|
105
|
+
var packing: Error = packed.pack(root)
|
|
106
|
+
if packing != OK:
|
|
107
|
+
return "Failed to pack %s: %s" % [path, error_string(packing)]
|
|
108
|
+
return _settled(packed)[1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
## Takes [param names] on [param node] as the tool's own changes, which a write saves as they are.
|
|
112
|
+
## Called before the tool sets them.
|
|
113
|
+
func asked(node: Node, names: Array) -> void:
|
|
114
|
+
var id: int = node.get_instance_id()
|
|
115
|
+
var on: Dictionary = _asked.get_or_add(id, {})
|
|
116
|
+
if not _before.has(id):
|
|
117
|
+
_before[id] = HeadlessCheck.stored(node)
|
|
118
|
+
for name: Variant in names:
|
|
119
|
+
var property: String = str(name)
|
|
120
|
+
on[property] = true
|
|
121
|
+
on[property.get_slice(":", 0)] = true
|
|
122
|
+
_reached.merge(HeadlessCheck.reached(node, property))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## Takes each copy in [param copies], keyed by the node it was made from, as holding what that node's
|
|
126
|
+
## file entry holds.
|
|
127
|
+
func copied(copies: Dictionary) -> void:
|
|
128
|
+
for source: Variant in copies:
|
|
129
|
+
var original: Node = source
|
|
130
|
+
var copy: Node = copies[source]
|
|
131
|
+
_copied_from[copy.get_instance_id()] = original.get_instance_id()
|
|
132
|
+
|
|
133
|
+
|
|
62
134
|
## Why [param scene_path] cannot be written while the editor has it open, or "".
|
|
63
135
|
static func unsaved_in_editor(scene_path: String) -> String:
|
|
64
|
-
if not EditorInterface.get_unsaved_scenes().has(scene_path):
|
|
136
|
+
if not Engine.is_editor_hint() or not EditorInterface.get_unsaved_scenes().has(scene_path):
|
|
65
137
|
return ""
|
|
66
138
|
return (
|
|
67
139
|
(
|
|
@@ -142,21 +214,84 @@ static func make_folder_for(file_path: String) -> String:
|
|
|
142
214
|
## Why [param script], loaded from [param script_path], cannot be attached because the editor could not
|
|
143
215
|
## compile it, or "". A script that did not compile has no base type, and the refusal built from that
|
|
144
216
|
## 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.
|
|
217
|
+
## failure was a class it called being behind in the editor. A headless engine compiles every
|
|
218
|
+
## script from its file, so only an editor can hold a copy that is behind.
|
|
146
219
|
static func uncompiled(script: Script, script_path: String) -> String:
|
|
147
220
|
if not str(script.get_instance_base_type()).is_empty():
|
|
148
221
|
return ""
|
|
222
|
+
if not Engine.is_editor_hint():
|
|
223
|
+
return (
|
|
224
|
+
(
|
|
225
|
+
"%s does not compile, so it cannot be attached. script_diagnostics on it gives the "
|
|
226
|
+
+ "parser's errors."
|
|
227
|
+
)
|
|
228
|
+
% script_path
|
|
229
|
+
)
|
|
149
230
|
return (
|
|
150
231
|
(
|
|
151
|
-
"%s does not compile in the editor, so it cannot be attached. script_diagnostics on it
|
|
152
|
-
+ "the parser's errors. If they deny a member that another class's file declares,
|
|
153
|
-
+ "editor's copy of that class is behind: editor_rescan with reloadScript on that
|
|
154
|
-
+ "ask again."
|
|
232
|
+
"%s does not compile in the editor, so it cannot be attached. script_diagnostics on it "
|
|
233
|
+
+ "gives the parser's errors. If they deny a member that another class's file declares, "
|
|
234
|
+
+ "the editor's copy of that class is behind: editor_rescan with reloadScript on that "
|
|
235
|
+
+ "class, then ask again."
|
|
155
236
|
)
|
|
156
237
|
% script_path
|
|
157
238
|
)
|
|
158
239
|
|
|
159
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
|
+
|
|
160
295
|
## Why no node can be called [param node_name], wherever it sits, or "".
|
|
161
296
|
static func unfit_name(node_name: String) -> String:
|
|
162
297
|
if node_name.is_empty():
|
|
@@ -221,6 +356,26 @@ func write(check: Callable = Callable()) -> Dictionary:
|
|
|
221
356
|
var packing: Error = packed.pack(root)
|
|
222
357
|
if packing != OK:
|
|
223
358
|
return refuse("Failed to pack %s: %s" % [path, error_string(packing)])
|
|
359
|
+
var headless: bool = not Engine.is_editor_hint()
|
|
360
|
+
if headless:
|
|
361
|
+
var settled: Array = _settled(packed)
|
|
362
|
+
if not str(settled[1]).is_empty():
|
|
363
|
+
return refuse("Nothing was written: " + str(settled[1]))
|
|
364
|
+
packed = settled[0]
|
|
365
|
+
if not path.ends_with(".tscn") and _holds_scripted_embedded():
|
|
366
|
+
return refuse(
|
|
367
|
+
(
|
|
368
|
+
(
|
|
369
|
+
(
|
|
370
|
+
"Nothing was written: %s is a binary scene holding an embedded resource with a script "
|
|
371
|
+
+ "that is not a tool, and what that script changed when a headless engine loaded it "
|
|
372
|
+
+ "cannot be told apart from the file here. "
|
|
373
|
+
)
|
|
374
|
+
% path
|
|
375
|
+
)
|
|
376
|
+
+ HeadlessCheck.REMEDY
|
|
377
|
+
)
|
|
378
|
+
)
|
|
224
379
|
if check.is_valid():
|
|
225
380
|
var saved: Node = packed.instantiate(PackedScene.GEN_EDIT_STATE_MAIN)
|
|
226
381
|
if saved == null:
|
|
@@ -230,7 +385,33 @@ func write(check: Callable = Callable()) -> Dictionary:
|
|
|
230
385
|
if not missing.is_empty():
|
|
231
386
|
return refuse("Nothing was written: " + missing)
|
|
232
387
|
close()
|
|
233
|
-
|
|
388
|
+
var previous: Variant = null
|
|
389
|
+
if FileAccess.file_exists(path):
|
|
390
|
+
previous = FileAccess.get_file_as_bytes(path)
|
|
391
|
+
var written: Dictionary = save_packed(packed, path)
|
|
392
|
+
if not written.is_empty() or not headless or not path.ends_with(".tscn"):
|
|
393
|
+
return written
|
|
394
|
+
var state: SceneState = packed.get_state()
|
|
395
|
+
var values: Array = []
|
|
396
|
+
for index: int in state.get_node_count():
|
|
397
|
+
values.append_array(_stored_at(state, index).values())
|
|
398
|
+
var unkept: String = HeadlessCheck.settle_saved(
|
|
399
|
+
path, _text, values, null, {}, _reached, func() -> Error: return save_keeping_uid(packed, path)
|
|
400
|
+
)
|
|
401
|
+
if unkept.is_empty():
|
|
402
|
+
return {}
|
|
403
|
+
return {"ok": false, "error": "Nothing was written: " + unkept + HeadlessCheck.put_back(path, previous)}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
func _holds_scripted_embedded() -> bool:
|
|
407
|
+
var seen: Dictionary = {}
|
|
408
|
+
var nodes: Array[Node] = [root]
|
|
409
|
+
while not nodes.is_empty():
|
|
410
|
+
var node: Node = nodes.pop_back()
|
|
411
|
+
if HeadlessCheck.holds_scripted_embedded(node, seen):
|
|
412
|
+
return true
|
|
413
|
+
nodes.append_array(node.get_children())
|
|
414
|
+
return false
|
|
234
415
|
|
|
235
416
|
|
|
236
417
|
## Writes [param packed] to [param scene_path], making its directory, and brings the editor up to
|
|
@@ -241,18 +422,51 @@ static func save_packed(packed: PackedScene, scene_path: String) -> Dictionary:
|
|
|
241
422
|
var made: Error = DirAccess.make_dir_recursive_absolute(directory)
|
|
242
423
|
if made != OK:
|
|
243
424
|
return {"ok": false, "error": "Could not create %s: %s" % [directory, error_string(made)]}
|
|
244
|
-
var saving: Error =
|
|
425
|
+
var saving: Error = save_keeping_uid(packed, scene_path)
|
|
245
426
|
if saving != OK:
|
|
246
427
|
return {"ok": false, "error": "Failed to save %s: %s" % [scene_path, error_string(saving)]}
|
|
247
428
|
refresh(scene_path)
|
|
248
429
|
return {}
|
|
249
430
|
|
|
250
431
|
|
|
432
|
+
## Saves [param resource] at [param file_path], keeping the uid the file already has.
|
|
433
|
+
##
|
|
434
|
+
## The editor saves through its uid cache, which knows every file's. A headless engine that has not
|
|
435
|
+
## imported the project has none, and a resave dropped the uid from the header, so every reference
|
|
436
|
+
## made through it stopped resolving. Read from the file itself, since the loader does not answer it
|
|
437
|
+
## for a text scene there, and a new file is given one as the editor gives it.
|
|
438
|
+
static func save_keeping_uid(resource: Resource, file_path: String) -> Error:
|
|
439
|
+
if Engine.is_editor_hint():
|
|
440
|
+
return ResourceSaver.save(resource, file_path)
|
|
441
|
+
var uid: int = _uid_in(file_path)
|
|
442
|
+
var saving: Error = ResourceSaver.save(resource, file_path)
|
|
443
|
+
if saving != OK:
|
|
444
|
+
return saving
|
|
445
|
+
if uid == ResourceUID.INVALID_ID:
|
|
446
|
+
uid = ResourceUID.create_id()
|
|
447
|
+
return ResourceSaver.set_uid(file_path, uid)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
## The uid the file at [param file_path] declares, or [constant ResourceUID.INVALID_ID].
|
|
451
|
+
static func _uid_in(file_path: String) -> int:
|
|
452
|
+
if not FileAccess.file_exists(file_path):
|
|
453
|
+
return ResourceUID.INVALID_ID
|
|
454
|
+
var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
|
|
455
|
+
if file != null:
|
|
456
|
+
var header: RegExMatch = RegEx.create_from_string('uid="(uid://[a-z0-9]+)"').search(file.get_line())
|
|
457
|
+
file.close()
|
|
458
|
+
if header != null:
|
|
459
|
+
return ResourceUID.text_to_id(header.get_string(1))
|
|
460
|
+
return ResourceLoader.get_resource_uid(file_path)
|
|
461
|
+
|
|
462
|
+
|
|
251
463
|
## Brings the editor up to date with a scene file written under it: the cached copy anything holds,
|
|
252
464
|
## the editor's file list, and the tab showing it, whether current or not.
|
|
253
465
|
static func refresh(scene_path: String) -> void:
|
|
254
466
|
if ResourceLoader.has_cached(scene_path):
|
|
255
467
|
var _replaced: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_REPLACE)
|
|
468
|
+
if not Engine.is_editor_hint():
|
|
469
|
+
return
|
|
256
470
|
scan_later()
|
|
257
471
|
if EditorInterface.get_open_scenes().has(scene_path):
|
|
258
472
|
EditorInterface.reload_scene_from_path(scene_path)
|
|
@@ -294,7 +508,191 @@ static func _start_scan() -> void:
|
|
|
294
508
|
## Whether the editor holds unsaved changes to [param scene_path], for a read to say that the file
|
|
295
509
|
## it answers from is not what the editor shows.
|
|
296
510
|
static func unsaved(scene_path: String) -> bool:
|
|
297
|
-
return EditorInterface.get_unsaved_scenes().has(scene_path)
|
|
511
|
+
return Engine.is_editor_hint() and EditorInterface.get_unsaved_scenes().has(scene_path)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
## [param packed] with what the scene's scripts changed put back, as [pack, ""], or [null, why not].
|
|
515
|
+
##
|
|
516
|
+
## On a node the tool did not ask to change, a difference is put back to what the file holds, or, for
|
|
517
|
+
## a property the file does not set, to what the node would load with without it. On a node it did
|
|
518
|
+
## ask to change, through a script that is not a tool, every property it did not name is put back to
|
|
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.
|
|
522
|
+
func _settled(packed: PackedScene) -> Array:
|
|
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)]]
|
|
547
|
+
var first: Array = left[0]
|
|
548
|
+
var stray_node: Node = first[0]
|
|
549
|
+
var named: String = str(first[1])
|
|
550
|
+
if first[1] is Array:
|
|
551
|
+
var names: Array = first[1]
|
|
552
|
+
named = str(names[0])
|
|
553
|
+
return [null, _stray_text(stray_node, named)]
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
## Each node the tool asked to change through a script that is not a tool, with the properties it did
|
|
557
|
+
## not name that no longer hold what they held before, as [node, names].
|
|
558
|
+
func _moved_by_scripts() -> Array[Array]:
|
|
559
|
+
var moved: Array[Array] = []
|
|
560
|
+
for key: Variant in _asked:
|
|
561
|
+
var id: int = key
|
|
562
|
+
var node: Node = instance_from_id(id) as Node
|
|
563
|
+
if node == null or not root.is_ancestor_of(node) and node != root:
|
|
564
|
+
continue
|
|
565
|
+
var on: Dictionary = _asked[id]
|
|
566
|
+
if not HeadlessCheck.through_script(node, on):
|
|
567
|
+
continue
|
|
568
|
+
var before: Dictionary = _before[id]
|
|
569
|
+
var changed: Array[String] = HeadlessCheck.differing(node, before, on)
|
|
570
|
+
if not changed.is_empty():
|
|
571
|
+
moved.append([node, changed])
|
|
572
|
+
return moved
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
## Every property [param state] stores differently from the file, on a node the tool did not ask to
|
|
576
|
+
## change it on, as [node, name, whether the file stores it, the file's value].
|
|
577
|
+
func _strays(state: SceneState) -> Array[Array]:
|
|
578
|
+
var stray: Array[Array] = []
|
|
579
|
+
for index: int in state.get_node_count():
|
|
580
|
+
var node: Node = _node_at(str(state.get_node_path(index)))
|
|
581
|
+
if node == null:
|
|
582
|
+
continue
|
|
583
|
+
var id: int = node.get_instance_id()
|
|
584
|
+
if _asked.has(id):
|
|
585
|
+
continue
|
|
586
|
+
var filed: Dictionary = _filed.get(id, _filed.get(_copied_from.get(id, 0), {}))
|
|
587
|
+
# A node is instanced before it is added to its parent, which is before its children are, so
|
|
588
|
+
# what a script does as a scene loads stays on the script's own node. Anything else a pack
|
|
589
|
+
# changes is Godot's doing and the editor's pack does it too: a Control saved without its
|
|
590
|
+
# grow directions gets them back from anchors_preset's setter, and loses an anchors_preset
|
|
591
|
+
# its layout mode does not store.
|
|
592
|
+
if not HeadlessCheck.untooled(node.get_script()) and not HeadlessCheck.untooled(filed.get("script")):
|
|
593
|
+
continue
|
|
594
|
+
var stored: Dictionary = _stored_at(state, index)
|
|
595
|
+
var names: Dictionary = filed.duplicate()
|
|
596
|
+
names.merge(stored)
|
|
597
|
+
for key: Variant in names:
|
|
598
|
+
var name: String = str(key)
|
|
599
|
+
var held: bool = filed.has(name)
|
|
600
|
+
if held == stored.has(name) and (not held or PropertyValues.same(stored[name], filed[name])):
|
|
601
|
+
continue
|
|
602
|
+
if not held and HeadlessCheck.at_default(node, name):
|
|
603
|
+
continue
|
|
604
|
+
stray.append([node, name, held, filed.get(name)])
|
|
605
|
+
return stray
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
func _stray_text(node: Node, name: String) -> String:
|
|
609
|
+
var at: String = str(root.name) if node == root else path_of(node)
|
|
610
|
+
if name == "script":
|
|
611
|
+
return (
|
|
612
|
+
(
|
|
613
|
+
(
|
|
614
|
+
"the script of %s did not start when a headless engine instanced %s, so the node would be "
|
|
615
|
+
+ "saved without it; the engine's messages give its errors. "
|
|
616
|
+
)
|
|
617
|
+
% [at, path]
|
|
618
|
+
)
|
|
619
|
+
+ HeadlessCheck.REMEDY
|
|
620
|
+
)
|
|
621
|
+
return (
|
|
622
|
+
(
|
|
623
|
+
(
|
|
624
|
+
"%s.%s would be saved differently from what %s holds, and the call did not ask for it: the "
|
|
625
|
+
+ "scene's scripts ran when a headless engine instanced it, as they do in the game and not in "
|
|
626
|
+
+ "an editor. "
|
|
627
|
+
)
|
|
628
|
+
% [at, name, path]
|
|
629
|
+
)
|
|
630
|
+
+ HeadlessCheck.REMEDY
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
## What [param node] would load with for [param name] if this file did not set it: what the scenes
|
|
635
|
+
## it comes from store, followed down through every scene instanced or inherited, else its script's
|
|
636
|
+
## or class's default. Read from their files, since instancing them would run their scripts.
|
|
637
|
+
func _unset_value(node: Node, name: String) -> Variant:
|
|
638
|
+
var base: SceneState = _state.get_base_scene_state() if _state != null else null
|
|
639
|
+
if node == root or _base_paths.has(path_of(node)):
|
|
640
|
+
return _value_in(base, path_of(node), node, name)
|
|
641
|
+
var top: Node = node
|
|
642
|
+
while top != root and top.owner != root:
|
|
643
|
+
top = top.get_parent()
|
|
644
|
+
if top == root or top.scene_file_path.is_empty():
|
|
645
|
+
return HeadlessCheck.default_of(node, name)
|
|
646
|
+
if _base_paths.has(path_of(top)):
|
|
647
|
+
return _value_in(base, path_of(node), node, name)
|
|
648
|
+
var instanced: PackedScene = ResourceLoader.load(top.scene_file_path) as PackedScene
|
|
649
|
+
return _value_in(
|
|
650
|
+
instanced.get_state() if instanced != null else null, str(top.get_path_to(node)), node, name
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
## What [param state] gives the node at [param at] for [param name], following it into the scene an
|
|
655
|
+
## enclosing node instances, or into the base scene, until a file sets it.
|
|
656
|
+
func _value_in(state: SceneState, at: String, node: Node, name: String) -> Variant:
|
|
657
|
+
while state != null:
|
|
658
|
+
var inside: String = ""
|
|
659
|
+
var instanced: PackedScene = null
|
|
660
|
+
for index: int in state.get_node_count():
|
|
661
|
+
var here: String = _relative(str(state.get_node_path(index)))
|
|
662
|
+
if here == at:
|
|
663
|
+
for property: int in state.get_node_property_count(index):
|
|
664
|
+
if str(state.get_node_property_name(index, property)) == name:
|
|
665
|
+
return state.get_node_property_value(index, property)
|
|
666
|
+
var scene: PackedScene = state.get_node_instance(index)
|
|
667
|
+
if scene == null:
|
|
668
|
+
continue
|
|
669
|
+
var encloses: bool = here == at or here == "." or at.begins_with(here + "/")
|
|
670
|
+
if encloses and (instanced == null or here.length() > inside.length() or inside == "."):
|
|
671
|
+
inside = here
|
|
672
|
+
instanced = scene
|
|
673
|
+
if instanced != null:
|
|
674
|
+
if inside == at:
|
|
675
|
+
at = "."
|
|
676
|
+
elif inside != ".":
|
|
677
|
+
at = at.substr(inside.length() + 1)
|
|
678
|
+
state = instanced.get_state()
|
|
679
|
+
else:
|
|
680
|
+
state = state.get_base_scene_state()
|
|
681
|
+
return HeadlessCheck.default_of(node, name)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
func _node_at(node_path: String) -> Node:
|
|
685
|
+
var at: String = _relative(node_path)
|
|
686
|
+
return root if at == "." else root.get_node_or_null(NodePath(at))
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
static func _stored_at(state: SceneState, index: int) -> Dictionary:
|
|
690
|
+
var stored: Dictionary = {}
|
|
691
|
+
for property: int in state.get_node_property_count(index):
|
|
692
|
+
stored[str(state.get_node_property_name(index, property))] = (state.get_node_property_value(
|
|
693
|
+
index, property
|
|
694
|
+
))
|
|
695
|
+
return stored
|
|
298
696
|
|
|
299
697
|
|
|
300
698
|
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
|
|
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
|
-
#
|
|
534
|
-
#
|
|
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.
|