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