gdharness 1.2.41 → 1.2.42

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 CHANGED
@@ -10969,6 +10969,34 @@ function scriptsWithoutUid(projectPath) {
10969
10969
  visit(projectPath, "");
10970
10970
  return found.sort();
10971
10971
  }
10972
+ function pathsInUidCache(projectPath) {
10973
+ let data;
10974
+ try {
10975
+ data = readFileSync9(join13(projectPath, ".godot", "uid_cache.bin"));
10976
+ } catch {
10977
+ return null;
10978
+ }
10979
+ if (data.length < 4) {
10980
+ return null;
10981
+ }
10982
+ const count = data.readUInt32LE(0);
10983
+ const paths = new Map;
10984
+ let at = 4;
10985
+ for (let entry = 0;entry < count; entry++) {
10986
+ if (at + 12 > data.length) {
10987
+ return null;
10988
+ }
10989
+ const id = data.readBigUInt64LE(at);
10990
+ const length = data.readUInt32LE(at + 8);
10991
+ at += 12;
10992
+ if (at + length > data.length) {
10993
+ return null;
10994
+ }
10995
+ paths.set(id, data.toString("utf8", at, at + length));
10996
+ at += length;
10997
+ }
10998
+ return new Set(paths.values());
10999
+ }
10972
11000
  function isBinary(bytes) {
10973
11001
  return bytes.subarray(0, 8000).includes(0);
10974
11002
  }
@@ -11871,7 +11899,7 @@ function annotationsOf(spec) {
11871
11899
  openWorldHint: false
11872
11900
  };
11873
11901
  }
11874
- var PROJECT_INFO_SECTIONS, PROJECT_PATH, RUNNING_PROJECT_PATH, RUNNING_PID, SCENE_PATH, WITHOUT_AN_EDITOR = "Through the editor when one serves the project; with none, a headless engine runs the editor's own tool and the answer says so under writtenBy, or readBy for a read. That engine runs every script of the file, where the editor runs only tool scripts, so what a script changes as the file loads or as a property is set is put back before anything is saved, and a file where that cannot be done, such as a scene whose script does not compile, is refused and nothing is written. An editor of the project that is running and has not reached this server is waited for rather than written around.", WHOLE_NUMBERS = 'A whole number of 2^53 or more, such as a 64-bit seed or a resource uid, is written as its digits, {"_type": "int", "value": "9007199254740993"}, the form a read answers with: a JSON number that large can arrive as a neighbour of the one sent, so sent as a number it is refused.', SCRIPT_PATH, RESOURCE_FILE = 'File inside the project, such as "sprites/hero.png" or "materials/steel.tres".', RESOURCE_PATH, NODE_PATH, PROPERTIES, XY, ANIMATION_TRACK, SCRIPT_MODIFICATIONS, INPUT_EVENTS, TILESET_SOURCES, TILEMAP_CELLS, COLOUR2, TOOL_SPECS, SPECS_BY_NAME;
11902
+ var PROJECT_INFO_SECTIONS, PROJECT_PATH, RUNNING_PROJECT_PATH, RUNNING_PID, SCENE_PATH, WITHOUT_AN_EDITOR = "Through the editor when one serves the project; with none, a headless engine runs the editor's own tool and the answer says so under writtenBy, or readBy for a read. That engine runs every script of the file, where the editor runs only tool scripts, so what a script changes as the file loads or as a property is set is put back before anything is saved, and a file where that cannot be done, such as a scene whose script does not compile, is refused and nothing is written. Each file the written one refers to is named by its uid, as the editor names it, except a file the project's uid cache has no uid for yet (one made since the project was last scanned), which is named by path and listed under namedByPath with the call that scans it. An editor of the project that is running and has not reached this server is waited for rather than written around.", WHOLE_NUMBERS = 'A whole number of 2^53 or more, such as a 64-bit seed or a resource uid, is written as its digits, {"_type": "int", "value": "9007199254740993"}, the form a read answers with: a JSON number that large can arrive as a neighbour of the one sent, so sent as a number it is refused.', SCRIPT_PATH, RESOURCE_FILE = 'File inside the project, such as "sprites/hero.png" or "materials/steel.tres".', RESOURCE_PATH, NODE_PATH, PROPERTIES, XY, ANIMATION_TRACK, SCRIPT_MODIFICATIONS, INPUT_EVENTS, TILESET_SOURCES, TILEMAP_CELLS, COLOUR2, TOOL_SPECS, SPECS_BY_NAME;
11875
11903
  var init_tool_definitions = __esm(() => {
11876
11904
  init_launch();
11877
11905
  PROJECT_INFO_SECTIONS = [
@@ -12273,7 +12301,7 @@ var init_tool_definitions = __esm(() => {
12273
12301
  operations: {
12274
12302
  status: {
12275
12303
  effect: "read",
12276
- summary: "which resources are outdated or failed, or one resource with resourcePath, each with its reason. Judged by content with the checks the editor makes before it reimports: a sidecar changed since its import, as setting an option without a reimport leaves it; a sidecar with no uid or written for another file; an output under .godot/imported that is gone (missing_outputs) or changed; a source whose hash differs from the one its import recorded, or with no record. Beyond the editor, a glTF scene whose import does not depend on an image it names, because it was built before that image was imported (imported_without; a scene whose import runs an import script is not judged on images). failed is an import that failed, which the editor never tries again on its own. Without resourcePath, a sidecar whose source is gone is listed as missing_source",
12304
+ summary: "which resources are outdated or failed, or one resource with resourcePath, each with its reason and a status the summary counts: needs_reimport (out of date, or a file the engine imports that has never been imported), failed, up_to_date, missing_source, or not_imported (a file the engine does not import at all, with why). Judged by content with the checks the editor makes before it reimports: a sidecar changed since its import, as setting an option without a reimport leaves it; a sidecar with no uid or written for another file; an output under .godot/imported that is gone (missing_outputs) or changed; a source whose hash differs from the one its import recorded, or with no record. Beyond the editor, a glTF scene whose import does not depend on an image it names, because it was built before that image was imported (imported_without; a scene whose import runs an import script is not judged on images). failed is an import that failed, which the editor never tries again on its own. Without resourcePath, a sidecar whose source is gone is listed as missing_source",
12277
12305
  requires: []
12278
12306
  },
12279
12307
  options: { effect: "read", summary: "the import options of one resource", requires: ["resourcePath"] },
@@ -12294,7 +12322,7 @@ var init_tool_definitions = __esm(() => {
12294
12322
  },
12295
12323
  refresh_uids: {
12296
12324
  effect: "additive",
12297
- summary: "import the project so every script and shader has its .uid sidecar, and name under uidsCreated the ones this made and under stillWithoutUid the ones the engine would not import: it writes no scene and no script, so a project whose sidecars are all present is left untouched",
12325
+ summary: "import the project so every script and shader has its .uid sidecar and the uid cache holds the uid of every file, scenes made with no editor among them, which a headless write names a reference by (see namedByPath), and name under uidsCreated the sidecars this made, under uidsCached the res:// files whose uid the cache holds now and did not before, and under stillWithoutUid the ones the engine would not import: it writes no scene and no script, so a project whose sidecars are all present is left untouched",
12298
12326
  requires: []
12299
12327
  },
12300
12328
  refresh_classes: {
@@ -12393,7 +12421,12 @@ var init_tool_definitions = __esm(() => {
12393
12421
  rootNodeName: {
12394
12422
  type: "string",
12395
12423
  ops: ["create"],
12396
- description: "create: the root node name. Default the class name, as the editor names a new root."
12424
+ description: "create: the root node name. Default the class name, as the editor names a new root, or with inherits the name of that scene's root."
12425
+ },
12426
+ inherits: {
12427
+ type: "string",
12428
+ ops: ["create"],
12429
+ description: `create: a scene for the new one to inherit, such as "parts/crate.tscn", as the editor's New Inherited Scene makes one. Its root is an instance of that scene, so changes there reach it, and scene_node set gives the nodes it inherits values of this scene's own. The root's class is that scene's, so rootNodeType is not given with it; the answer names it under rootNodeType and the scene under inherits.`
12397
12430
  },
12398
12431
  script: { type: "string", ops: ["create"], description: "create: a script to attach to the root." },
12399
12432
  newPath: { type: "string", ops: ["save_as"], description: "save_as: where the copy goes." }
@@ -12414,11 +12447,14 @@ var init_tool_definitions = __esm(() => {
12414
12447
  name: "scene_tree",
12415
12448
  title: "Scene tree",
12416
12449
  effect: "read",
12417
- description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. A node that instances another scene names that file under instanceOf, and the nodes listed under it come from that file rather than this one. Needs no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without making its nodes, so no script of theirs runs (autoloads still start once it has answered, as in every headless call). For the nodes of a game that is running, runtime_inspect tree.",
12450
+ description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. A node that instances another scene names that file under instanceOf, and the nodes listed under it come from that file rather than this one; its editableChildren says whether this scene, or a scene it inherits, keeps values of its own for them (scene_node set editableChildren). Needs no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without making its nodes, so no script of theirs runs (autoloads still start once it has answered, as in every headless call). For the nodes of a game that is running, runtime_inspect tree.",
12418
12451
  parameters: {
12419
12452
  projectPath: PROJECT_PATH,
12420
12453
  scenePath: SCENE_PATH,
12421
- depth: { type: "number", description: "How many levels to descend. Default: all." },
12454
+ depth: {
12455
+ type: "number",
12456
+ description: "How many levels to descend. Default: all. A node at the last level that has children of its own lists none and says how many under childrenNotListed."
12457
+ },
12422
12458
  includeProperties: { type: "boolean", description: "Include each node's properties. Default false." }
12423
12459
  },
12424
12460
  requires: ["projectPath", "scenePath"]
@@ -12426,7 +12462,7 @@ var init_tool_definitions = __esm(() => {
12426
12462
  {
12427
12463
  name: "scene_node",
12428
12464
  title: "Scene nodes",
12429
- description: `One node in a scene file: add, read, set, duplicate, reparent or delete it, or paint TileMap cells. Any ClassDB node type or script class can be added, so a NavigationRegion2D, an AnimationTree or a Camera3D is an add with that nodeType and its properties, and another scene is instanced by an add with instancePath. A set with properties {"name": "Boss"} renames a node. A property holding a Resource takes the res:// path of one, so a texture, a material or a theme is a set like any other. A write is refused while the editor holds unsaved changes to the scene, and for a node that belongs to a scene this one instances or inherits. ${WITHOUT_AN_EDITOR}`,
12465
+ description: `One node in a scene file: add, read, set, duplicate, reparent or delete it, or paint TileMap cells. Any ClassDB node type or script class can be added, so a NavigationRegion2D, an AnimationTree or a Camera3D is an add with that nodeType and its properties, and another scene is instanced by an add with instancePath. A set with properties {"name": "Boss"} renames a node, and answers nodePath as its new path. A rename or a reparent carries the node paths naming the node, as the editor's rename does: NodePath values on nodes (in arrays, dictionaries, built-in resources, %unique paths) and the tracks its players' animations hold, saving a library kept in a .tres of its own. Each rewrite is listed under retargeted ({scenePath, nodePath, property or animation and track, from, to}), and under notRetargeted, with a reason, each left: a track kept in another scene or an imported file, or a value another scene gives that the move left naming nothing. The move is followed into every other .tscn that instances or inherits this scene at any depth: the editable children, values, added nodes and connections named by the old path are rewritten, since Godot finds a moved node's values by unique_id but editable children only by path and drops the values kept below them on the next save, and that scene's node paths and tracks are carried as above. A reparent also drops the node ids saved along each rewritten path, which ran through the old parent; the next save writes them again. The scenes rewritten are named under followed, and under notFollowed any left as they were, with why. A delete rewrites no path and lists under stillNaming each node path and track, here and in the scenes instancing or inheriting this one, that named the deleted node and now names nothing. A property holding a Resource takes the res:// path of one, so a texture, a material or a theme is a set like any other. A write is refused while the editor holds unsaved changes to the scene. A node this scene inherits takes values of this scene's own, and so does a node inside an instance of another scene once that instance's editableChildren is true; neither can be moved, renamed, copied or deleted, since the other scene makes it again on load. ${WITHOUT_AN_EDITOR}`,
12430
12466
  parameters: {
12431
12467
  projectPath: PROJECT_PATH,
12432
12468
  scenePath: SCENE_PATH,
@@ -12439,7 +12475,7 @@ var init_tool_definitions = __esm(() => {
12439
12475
  nodeType: {
12440
12476
  type: "string",
12441
12477
  ops: ["add"],
12442
- description: 'add: the node class, such as "CharacterBody2D", or a class_name a project script declares, which the node is made as with that script attached. Give this or instancePath.'
12478
+ description: `add: the node class, such as "CharacterBody2D", or a class_name a project script declares, which the node is made as with that script attached. Give this or instancePath. The answer's nodeType is the class of the node made, for an instance the class of its scene's root.`
12443
12479
  },
12444
12480
  instancePath: {
12445
12481
  type: "string",
@@ -12448,6 +12484,11 @@ var init_tool_definitions = __esm(() => {
12448
12484
  },
12449
12485
  nodeName: { type: "string", ops: ["add"], description: "add: the new node's name." },
12450
12486
  properties: { ...PROPERTIES, ops: ["add", "set"] },
12487
+ editableChildren: {
12488
+ type: "boolean",
12489
+ ops: ["set"],
12490
+ description: "set: on an instance of another scene, whether its children are editable here, as the editor's Editable Children makes them. True lets scene_node set give the nodes inside that instance values of this scene's own, which no other instance shares. An instance nested inside another takes it once the one around it has editable children. False drops every value this scene keeps for them, and every node it added below one of them. The answer names the nodes whose values went under droppedValues (they stay, with the instance's own values) and the added nodes that went under droppedNodes; a node added straight under the instance stays. The answer says editableChildren as the file now holds it."
12491
+ },
12451
12492
  newName: { type: "string", ops: ["duplicate"], description: "duplicate: the copy's name." },
12452
12493
  newParentPath: {
12453
12494
  type: "string",
@@ -12481,17 +12522,17 @@ var init_tool_definitions = __esm(() => {
12481
12522
  },
12482
12523
  get: {
12483
12524
  effect: "read",
12484
- summary: "read a node's properties, and a TileMapLayer's or TileMap's cells as set_tilemap_cells takes them, the first 1000 with cellCount for all",
12525
+ summary: "read a node's properties, and a TileMapLayer's or TileMap's cells as set_tilemap_cells takes them, the first 1000 with cellCount for all. An instance of another scene also answers instanceOf and editableChildren, as scene_tree does",
12485
12526
  requires: ["nodePath"]
12486
12527
  },
12487
12528
  set: {
12488
12529
  effect: "destructive",
12489
- summary: "set properties on a node",
12490
- requires: ["nodePath", "properties"]
12530
+ summary: "set properties on a node, or make the children of an instance editable; give either or both",
12531
+ requires: ["nodePath"]
12491
12532
  },
12492
12533
  duplicate: {
12493
12534
  effect: "additive",
12494
- summary: "copy a node and its children",
12535
+ summary: "copy a node and its children, beside it or under parentNodePath, answering the copy's newNodePath, parentNodePath and index",
12495
12536
  requires: ["nodePath", "newName"]
12496
12537
  },
12497
12538
  reparent: {
@@ -12868,7 +12909,7 @@ var init_tool_definitions = __esm(() => {
12868
12909
  },
12869
12910
  move: {
12870
12911
  effect: "destructive",
12871
- summary: "move or rename a file or a directory, and every path in the project naming it",
12912
+ summary: "move or rename a file or a directory, and every path in the project naming it. With no editor an import pass then rebuilds the project's uid cache, so a reference by uid leads to the new path, and uidTable says whether it ran",
12872
12913
  requires: ["newPath"]
12873
12914
  }
12874
12915
  }
@@ -13106,8 +13147,8 @@ var init_tool_definitions = __esm(() => {
13106
13147
  name: "editor_status",
13107
13148
  title: "Editor status",
13108
13149
  effect: "read",
13109
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and which games with the runtime addon are reachable, of this project and of every other on the machine. Read connected with mayYetConnect: the editor dials this server and backs off between tries, so for the first half-minute after a server starts false means not reached yet as often as no editor; mayYetConnect true is worth waiting out, false is final, and editorCannotConnect, restartInterrupted, restartUnderway, closedHere and editorsNotYetConnected say why. An editor that has connected and not yet greeted reports nothing about itself, and every other call made in that window waits up to ten seconds for it to settle. playingInEditor null means unanswered rather than nothing playing: processActive and runtimes do not depend on the editor answering. game.run names the run editor_output and editor_run stop answer for, and stays named after it ends. addonIsStale, on the editor and on each game, says it runs addon code other than what this server ships, and staleNote says which half moves: the project, by a gdharness upgrade it spells out to run in a shell, the editor by a restart, or this server by a reconnect. A false editor.lspListening or editor.dapListening comes with serversNote, and editor_launch restart binds the port again. editor.scan says whether the editor is scanning, which editor_run waits out before starting a game. A game announced in a protocol this server does not speak is listed under unreadable, during an upgrade that moves the protocol, until the halves match.",
13110
- answer: "server names this server: its pid, its version, and lifecycleLog, the file every gdharness server of the user appends a line to when it starts, when its client first names itself, when it replaces another server of its project, and when it ends, with why it ended (the client closing its end as stdin:end, a signal by name, superseded with the successor's pid, an uncaught exception or rejection with its stack, or the exit code) and the calls it was answering then. The parent's command line follows on its own line a few seconds after the start. A server killed without warning writes no ended line, and on Windows every kill is that, so a started line with no ended line after it is a server something killed. " + "runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Its play says which play the editor is on: play.play counts the plays that editor has started since it opened, whoever started them, and play.editorPid is the editor, so two reads with different pairs are two plays even when both say playing. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it. It stays named after the run ends, beside processActive false, so editor_output can still read what that run printed; null when there is no run at all. startedAt says when it began, and endedAt when it ended, wherever something saw the end: a run adopted from a note another server wrote can have ended hours before this server started. editor.lspListening and editor.dapListening say whether anything accepts connections on the ports the editor names, lspPort and dapPort: Godot binds each once, as the editor starts, and reports a failure only in the log panel of the editor itself, so a false comes with serversNote, and editor_launch restart binds it again. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs the gdharness upgrade command, spelled out to run in a shell, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside editorCannotConnect is a project without the gdharness editor addon installed or enabled, where no editor can ever dial in, however young the server or whichever editor is open; its remedies say how to install or enable it. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. Any editor of this project running and not yet connected, whoever opened it, makes it true as well and is named under editorsNotYetConnected with its pid and start time, so a restart a reconnect interrupted after its launch is not read as nothing being there. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option. Every other call made while an editor is arriving, before its greeting or while it is looked up, waits up to ten seconds for it to settle rather than acting on connected as it reads in that window.",
13150
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and which games with the runtime addon are reachable, of this project and of every other on the machine. Read connected with mayYetConnect: the editor dials this server and backs off between tries, so for the first half-minute after a server starts false means not reached yet as often as no editor; mayYetConnect true is worth waiting out, false is final, and editorCannotConnect, restartInterrupted, restartUnderway, closedHere and editorsNotYetConnected say why. An editor that has connected and not yet greeted reports nothing about itself, and every other call made in that window waits up to ten seconds for it to settle. playingInEditor null means unanswered rather than nothing playing: processActive and runtimes do not depend on the editor answering. game.run names the run editor_output and editor_run stop answer for, and stays named after it ends. addonIsStale, on a connected editor and on each running game (absent with neither), says it runs addon code other than what this server ships, and staleNote says which half moves: the project, by a gdharness upgrade it spells out to run in a shell, the editor by a restart, or this server by a reconnect. A false editor.lspListening or editor.dapListening comes with serversNote, and editor_launch restart binds the port again. editor.scan says whether the editor is scanning, which editor_run waits out before starting a game. A game announced in a protocol this server does not speak is listed under unreadable, during an upgrade that moves the protocol, until the halves match.",
13151
+ answer: "projectIs, when present, is the gdharness version of the addons in the project, which differs from this server's (project_info says which of the two to move), and configIs, when present, the version the project's MCP config starts, which differs from this server's, so the next connection runs that one. server names this server: its pid, its version, and lifecycleLog, the file every gdharness server of the user appends a line to when it starts, when its client first names itself, when it replaces another server of its project, and when it ends, with why it ended (the client closing its end as stdin:end, a signal by name, superseded with the successor's pid, an uncaught exception or rejection with its stack, or the exit code) and the calls it was answering then. The parent's command line follows on its own line a few seconds after the start. A server killed without warning writes no ended line, and on Windows every kill is that, so a started line with no ended line after it is a server something killed. " + "runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Its play says which play the editor is on: play.play counts the plays that editor has started since it opened, whoever started them, and play.editorPid is the editor, so two reads with different pairs are two plays even when both say playing. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it. It stays named after the run ends, beside processActive false, so editor_output can still read what that run printed; null when there is no run at all. startedAt says when it began, and endedAt when it ended, wherever something saw the end: a run adopted from a note another server wrote can have ended hours before this server started. editor.lspListening and editor.dapListening say whether anything accepts connections on the ports the editor names, lspPort and dapPort: Godot binds each once, as the editor starts, and reports a failure only in the log panel of the editor itself, so a false comes with serversNote, and editor_launch restart binds it again. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs the gdharness upgrade command, spelled out to run in a shell, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside editorCannotConnect is a project without the gdharness editor addon installed or enabled, where no editor can ever dial in, however young the server or whichever editor is open; its remedies say how to install or enable it. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. Any editor of this project running and not yet connected, whoever opened it, makes it true as well and is named under editorsNotYetConnected with its pid and start time, so a restart a reconnect interrupted after its launch is not read as nothing being there. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option. Every other call made while an editor is arriving, before its greeting or while it is looked up, waits up to ten seconds for it to settle rather than acting on connected as it reads in that window. serverVersion is the gdharness version of this server. bridgeAvailable says the editor bridge is listening; when it failed to start, startupError gives the error, and retryingBridge is true while this server tries again. announcedAt is the file where this server told editors of its project which port to dial. connectedAt is when the editor connected, and lastPongAt when it last answered this server's keep-alive ping. addonDigest is a hash of the editor addon's code, which addonIsStale compares with what this server ships. debugPort is the port of the editor's remote debugger, the one games it plays connect to. openedByAServer says a gdharness server started this editor, and syncsBreakpoints that it keeps its breakpoints when a debug session opens, which Godot otherwise clears. pendingRequests counts the requests sent to the editor and not yet answered, and queuedResources the files with writes waiting behind another write to the same file.",
13111
13152
  parameters: {},
13112
13153
  requires: []
13113
13154
  },
@@ -35943,6 +35984,22 @@ function keepLate(id, answer) {
35943
35984
  function lateAnswer(id) {
35944
35985
  return lateAnswers.get(id);
35945
35986
  }
35987
+ function notSent(game, command, timeoutMs, refusals, dialling) {
35988
+ if (refusals > 0) {
35989
+ const refused = dialling ? `it refused ${refusals === 1 ? "one connection" : `${refusals} connections`} and had not accepted the next when the wait ended, after ${timeoutMs}ms` : `it refused ${refusals === 1 ? "the connection" : `all ${refusals} connections tried`} within ${timeoutMs}ms`;
35990
+ return {
35991
+ ok: false,
35992
+ reason: "refused",
35993
+ message: `The game (${game}) is running but nothing answered on its port: ${refused}. Nothing listens there while a game is starting or shutting down, and the engine refuses connections while more are waiting for the game than the eight it queues, which several servers calling one game at once, or a long frame or a breakpoint holding it, can make it do. Nothing was sent, so nothing ran.`
35994
+ };
35995
+ }
35996
+ const why = dialling ? "its port had not accepted the connection, which is how Linux and macOS hold one while the game's queue is full, as it is when the game has not taken the connections before it during a long frame or at a breakpoint" : `${UNTAKEN_PER_GAME} connections from this server were already waiting for the game to take them, which it does between frames, so a long frame or a breakpoint holds them`;
35997
+ return {
35998
+ ok: false,
35999
+ reason: "busy",
36000
+ message: `'${command}' was not sent to the game (${game}) within ${timeoutMs}ms: ${why}. Nothing was sent, so nothing ran.`
36001
+ };
36002
+ }
35946
36003
  function placeOf(endpoint) {
35947
36004
  return `${dialAddress(endpoint.address)}:${endpoint.port}`;
35948
36005
  }
@@ -35981,6 +36038,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, afterTheWait = "dr
35981
36038
  let buffered = "";
35982
36039
  let welcomed = false;
35983
36040
  let socket = null;
36041
+ let connected = false;
35984
36042
  let refusals = 0;
35985
36043
  let redial = null;
35986
36044
  let giveBackThePlace = null;
@@ -36017,16 +36075,8 @@ function runtimeRequest(endpoint, command, params, timeoutMs, afterTheWait = "dr
36017
36075
  settle(reply);
36018
36076
  };
36019
36077
  const timer = setTimeout(() => {
36020
- if (socket === null) {
36021
- finish(refusals > 0 ? {
36022
- ok: false,
36023
- reason: "refused",
36024
- message: `The game (${describe2(endpoint)}) is running but nothing answered on its port: it refused ${refusals === 1 ? "the connection" : `all ${refusals} connections tried`} within ${timeoutMs}ms. Nothing listens there while a game is starting or shutting down, and the engine refuses connections while more are waiting for the game than the eight it queues, which several servers calling one game at once, or a long frame or a breakpoint holding it, can make it do. Nothing was sent, so nothing ran.`
36025
- } : {
36026
- ok: false,
36027
- reason: "busy",
36028
- message: `'${command}' was not sent to the game (${describe2(endpoint)}) within ${timeoutMs}ms: ${UNTAKEN_PER_GAME} connections from this server were already waiting for the game to take them, which it does between frames, so a long frame or a breakpoint holds them. Nothing was sent, so nothing ran.`
36029
- });
36078
+ if (socket === null || !connected) {
36079
+ finish(notSent(describe2(endpoint), command, timeoutMs, refusals, socket !== null));
36030
36080
  return;
36031
36081
  }
36032
36082
  const still = welcomed ? `The game (${describe2(endpoint)}) has the request and did not answer '${command}' within ${timeoutMs}ms.` : `The game (${describe2(endpoint)}) did not answer '${command}' within ${timeoutMs}ms and had not taken the request: its frame has not ended since it was sent, which a long frame or a breakpoint does, and the request runs when it does.`;
@@ -36073,6 +36123,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, afterTheWait = "dr
36073
36123
  socket = opened;
36074
36124
  opened.setEncoding("utf8");
36075
36125
  opened.on("connect", () => {
36126
+ connected = true;
36076
36127
  opened.write(`${JSON.stringify({ id, command, params })}
36077
36128
  `);
36078
36129
  });
@@ -46636,7 +46687,7 @@ class GodotServer {
46636
46687
  project_addons_behind_this_server: {
46637
46688
  server_is: SERVER_VERSION,
46638
46689
  project_is: moved,
46639
- what_to_do: `The project's gdharness addons are ${moved}, older than this ${SERVER_VERSION} server, so an ` + "editor restart loads the old code and reconnecting spawns this same server again. Tell the " + `user, and offer to run ${gdharnessCommand(SERVER_VERSION, this.ownProject === null ? "upgrade" : `upgrade "${this.ownProject}"`)} ` + "in a shell, which replaces the addons with this server's; ask first, since it changes " + "files in their project. editor_launch restart then loads them."
46690
+ what_to_do: `The project's gdharness addons are ${moved}, older than this ${SERVER_VERSION} server. ` + "Restarting the editor would load that older addon code again, and reconnecting would start " + "this same server version again, so neither brings the two into step. Tell the " + `user, and offer to run ${gdharnessCommand(SERVER_VERSION, this.ownProject === null ? "upgrade" : `upgrade "${this.ownProject}"`)} ` + "in a shell, which replaces the addons with this server's; ask first, since it changes " + "files in their project. editor_launch restart then loads them."
46640
46691
  }
46641
46692
  });
46642
46693
  }
@@ -47465,18 +47516,24 @@ class GodotServer {
47465
47516
  const projectPath = project.value.path;
47466
47517
  const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
47467
47518
  const before = scriptsWithoutUid(projectPath);
47519
+ const cachedBefore = pathsInUidCache(projectPath);
47468
47520
  const imported = await runImport(engine.value, projectPath);
47469
47521
  if (!imported.ok) {
47470
47522
  return this.answer(withScanWait(imported, scanned));
47471
47523
  }
47472
47524
  const after = scriptsWithoutUid(projectPath);
47473
47525
  const given = before.filter((script) => !after.includes(script));
47526
+ const cachedAfter = pathsInUidCache(projectPath);
47527
+ const cached = cachedAfter === null ? null : [...cachedAfter].filter((path) => cachedBefore?.has(path) !== true).sort();
47474
47528
  return this.answer(withScanWait({
47475
47529
  ok: true,
47476
47530
  messages: imported.messages,
47477
47531
  ...imported.messagesOmitted === undefined ? {} : { messagesOmitted: imported.messagesOmitted },
47478
47532
  payload: {
47479
47533
  uidsCreated: given,
47534
+ ...cached === null ? {
47535
+ uidCacheNote: "The import pass left no uid cache this server could read, .godot/uid_cache.bin."
47536
+ } : { uidsCached: cached },
47480
47537
  stillWithoutUid: after,
47481
47538
  note: uidsLeftNote(after.length),
47482
47539
  ...imported.extensionNote === undefined ? {} : { extensionNote: imported.extensionNote },
@@ -52388,6 +52445,7 @@ var init_server = __esm(() => {
52388
52445
  PROJECT_FILE_ARGUMENTS = [
52389
52446
  "scenePath",
52390
52447
  "instancePath",
52448
+ "inherits",
52391
52449
  "scriptPath",
52392
52450
  "resourcePath",
52393
52451
  "newPath",
@@ -62,10 +62,13 @@ static func _bare(scene_path: String, node_path: String, kind: String) -> Array:
62
62
  var type: String = str(read.get("type", ""))
63
63
  if not ClassDB.class_exists(type) or not ClassDB.is_parent_class(type, kind):
64
64
  var said: String = "%s not found at: %s" % [kind, node_path]
65
- if read.is_empty() and FileReads.scene_node(scene_path, node_path.get_slice("/", 0)).is_empty():
66
- said = Read.from_the_root(
67
- said, node_path, str(FileReads.scene_node(scene_path, ".").get("name", ""))
68
- )
65
+ if read.is_empty():
66
+ var hinted: String = said
67
+ if FileReads.scene_node(scene_path, node_path.get_slice("/", 0)).is_empty():
68
+ hinted = Read.from_the_root(
69
+ said, node_path, str(FileReads.scene_node(scene_path, ".").get("name", ""))
70
+ )
71
+ said = hinted if hinted != said else FileReads.missing_from(said, scene_path, node_path)
69
72
  return [null, {"ok": false, "error": said}]
70
73
  var bare: Node = ClassDB.instantiate(type)
71
74
  bare.name = str(read["name"])
@@ -15,9 +15,10 @@ const Read = preload("reading.gd")
15
15
  const Serialisation = preload("serialisation.gd")
16
16
 
17
17
 
18
- ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf on a node that
19
- ## instances another scene and every stored property when [param include_properties] is set, the
20
- ## file's value or the default where it sets none. [param depth] -1 reads every level.
18
+ ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf and
19
+ ## editableChildren on a node that instances another scene and every stored property when
20
+ ## [param include_properties] is set, the file's value or the default where it sets none.
21
+ ## [param depth] -1 reads every level.
21
22
  static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
22
23
  var laid: Dictionary = _laid(scene_path)
23
24
  if not laid.get("ok", false):
@@ -41,6 +42,31 @@ static func scene_node(scene_path: String, node_path: String) -> Dictionary:
41
42
  return {"ok": true, "name": found["name"], "type": found["type"], "properties": found["properties"]}
42
43
 
43
44
 
45
+ ## [param said], the refusal for [param node_path], which [param scene_path] does not have, with
46
+ ## where the path leaves the scene as [method Read.missing_below] says it.
47
+ static func missing_from(said: String, scene_path: String, node_path: String) -> String:
48
+ var laid: Dictionary = _laid(scene_path)
49
+ if not laid.get("ok", false):
50
+ return said
51
+ var nodes: Dictionary = laid["nodes"]
52
+ var parts: PackedStringArray = node_path.trim_prefix("./").split("/", false)
53
+ var at: String = "."
54
+ var reached: PackedStringArray = []
55
+ for part: String in parts:
56
+ var below: String = _joined(at, part)
57
+ if not nodes.has(below):
58
+ break
59
+ at = below
60
+ var _walked: bool = reached.append(part)
61
+ if reached.size() >= parts.size():
62
+ return said
63
+ var names: PackedStringArray = []
64
+ var children: Array[String] = nodes[at]["children"]
65
+ for child: String in children:
66
+ var _named: bool = names.append(str(nodes[child]["name"]))
67
+ return Read.missing_below(said, str(nodes["."]["name"]), reached, parts[reached.size()], names)
68
+
69
+
44
70
  ## Every node of [param scene_path] as {ok, nodes}, keyed by path, or a refusal.
45
71
  static func _laid(scene_path: String) -> Dictionary:
46
72
  if not FileAccess.file_exists(scene_path):
@@ -51,13 +77,11 @@ static func _laid(scene_path: String) -> Dictionary:
51
77
  if loaded == null:
52
78
  return {"ok": false, "error": "Failed to load %s as a scene" % scene_path}
53
79
  if not loaded is PackedScene:
80
+ var kind: String = loaded.get_class()
81
+ var sort: String = ("an " if kind.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + kind
54
82
  return {
55
83
  "ok": false,
56
- "error":
57
- (
58
- "%s is a %s, not a scene: resource_edit read reads its properties."
59
- % [scene_path, loaded.get_class()]
60
- )
84
+ "error": "%s is %s, not a scene: resource_edit read reads its properties." % [scene_path, sort]
61
85
  }
62
86
  var packed: PackedScene = loaded
63
87
  var nodes: Dictionary = {}
@@ -82,8 +106,18 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
82
106
  var refused: String = _lay(base, prefix, nodes, within)
83
107
  if not refused.is_empty():
84
108
  return refused
109
+ var ids: Array[int] = _unique_ids(state.get_path(), state.get_node_count())
85
110
  for index: int in state.get_node_count():
86
111
  var path: String = _joined(prefix, str(state.get_node_path(index)))
112
+ var recovered: bool = false
113
+ if not nodes.has(path) and index > 0 and _overrides(state, index) and ids[index] >= 0:
114
+ # The engine finds an entry whose node was renamed or moved in the scene it comes from by
115
+ # the node's unique_id, and keeps the node's new name.
116
+ var parent: String = _joined(prefix, str(state.get_node_path(index, true)))
117
+ var found: String = _by_id(nodes, _owner_of(nodes, parent, prefix), ids[index])
118
+ recovered = not found.is_empty()
119
+ if recovered:
120
+ path = found
87
121
  var fresh: bool = not nodes.has(path)
88
122
  if fresh:
89
123
  var instance: PackedScene = state.get_node_instance(index)
@@ -98,6 +132,7 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
98
132
  if not nodes.has(path):
99
133
  return "%s holds no nodes" % instance.resource_path
100
134
  nodes[path]["instanceOf"] = instance.resource_path
135
+ nodes[path]["editableChildren"] = false
101
136
  elif state.is_node_instance_placeholder(index):
102
137
  nodes[path] = _made("InstancePlaceholder")
103
138
  nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
@@ -106,9 +141,12 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
106
141
  continue
107
142
  else:
108
143
  nodes[path] = _made(str(state.get_node_type(index)))
144
+ if ids[index] >= 0:
145
+ nodes[path]["uniqueId"] = ids[index]
109
146
  # The name is the state's even for an entry laid already, since an inherited scene names its
110
147
  # root itself; a root instanced inside another scene is renamed by that scene once laid.
111
- nodes[path]["name"] = str(state.get_node_name(index))
148
+ if not recovered:
149
+ nodes[path]["name"] = str(state.get_node_name(index))
112
150
  if fresh:
113
151
  # The root of what is laid here is placed by whoever instanced it.
114
152
  if path != prefix:
@@ -123,6 +161,86 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
123
161
  held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
124
162
  index, property
125
163
  )
164
+ # Only the scene being read and the scenes it inherits share its root, the owner whose editable
165
+ # instances a write here sets; a file instanced below makes its own owner's editable.
166
+ if prefix == ".":
167
+ for editable: String in _editable_in(state.get_path()):
168
+ var at: String = _joined(prefix, editable)
169
+ var marked: Dictionary = nodes.get(at, {})
170
+ if marked.has("editableChildren"):
171
+ marked["editableChildren"] = true
172
+ return ""
173
+
174
+
175
+ ## The paths of the instances [param scene_path] gives editable children, from its root. SceneState
176
+ ## does not give them to a script, so they are read off the scene's bundle.
177
+ static func _editable_in(scene_path: String) -> PackedStringArray:
178
+ var found: PackedStringArray = []
179
+ var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
180
+ if not loaded is PackedScene:
181
+ return found
182
+ var given: Variant = loaded.get("_bundled")
183
+ if not given is Dictionary:
184
+ return found
185
+ var bundled: Dictionary = given
186
+ var listed: Variant = bundled.get("editable_instances", [])
187
+ if listed is Array:
188
+ for one: Variant in listed:
189
+ var _kept: bool = found.append(str(one))
190
+ return found
191
+
192
+
193
+ ## The unique_id of each node entry in the text scene at [param scene_path], in the order its state
194
+ ## holds them, -1 where an entry has none. All -1 for a binary scene, and for a file whose entries do
195
+ ## not number [param count], since SceneState does not give the ids to a script.
196
+ static func _unique_ids(scene_path: String, count: int) -> Array[int]:
197
+ var ids: Array[int] = []
198
+ var _sized: int = ids.resize(count)
199
+ ids.fill(-1)
200
+ if not scene_path.ends_with(".tscn") or not FileAccess.file_exists(scene_path):
201
+ return ids
202
+ var read: Array[int] = []
203
+ var pattern: RegEx = RegEx.create_from_string(" unique_id=(\\d+)")
204
+ for line: String in FileAccess.get_file_as_string(scene_path).split("\n"):
205
+ if line.begins_with("[node "):
206
+ var found: RegExMatch = pattern.search(line)
207
+ read.append(int(found.get_string(1)) if found != null else -1)
208
+ return read if read.size() == count else ids
209
+
210
+
211
+ ## Whether entry [param index] of [param state] only sets values on a node another file makes.
212
+ static func _overrides(state: SceneState, index: int) -> bool:
213
+ return (
214
+ state.get_node_instance(index) == null
215
+ and not state.is_node_instance_placeholder(index)
216
+ and str(state.get_node_type(index)).is_empty()
217
+ )
218
+
219
+
220
+ ## The node whose file owns what sits under [param parent]: the nearest instance at or above it, or
221
+ ## [param prefix], the root of what is being laid, as the engine searches.
222
+ static func _owner_of(nodes: Dictionary, parent: String, prefix: String) -> String:
223
+ var at: String = parent
224
+ while at != prefix and at != "." and nodes.has(at):
225
+ var entry: Dictionary = nodes[at]
226
+ if entry.has("instanceOf"):
227
+ break
228
+ at = "." if not at.contains("/") else at.get_base_dir()
229
+ return at if nodes.has(at) else prefix
230
+
231
+
232
+ ## The path of the node [param owner] owns whose unique_id is [param id], or "": one under an instance
233
+ ## nested below [param owner] belongs to that instance's file, whose ids are numbered apart.
234
+ static func _by_id(nodes: Dictionary, owner: String, id: int) -> String:
235
+ for path: String in nodes:
236
+ if path == owner or not (owner == "." or path.begins_with(owner + "/")):
237
+ continue
238
+ var entry: Dictionary = nodes[path]
239
+ if Read.as_int(entry.get("uniqueId", -1), -1) != id:
240
+ continue
241
+ var above: String = "." if not path.contains("/") else path.get_base_dir()
242
+ if _owner_of(nodes, above, owner) == owner:
243
+ return path
126
244
  return ""
127
245
 
128
246
 
@@ -162,12 +280,18 @@ static func _entry(
162
280
  # The root's file is the scene being read, so only another node's names a second scene.
163
281
  if path != "." and node.has("instanceOf"):
164
282
  data["instanceOf"] = node["instanceOf"]
283
+ if node.has("editableChildren"):
284
+ data["editableChildren"] = node["editableChildren"]
165
285
  if include_properties:
166
286
  var given: Dictionary = node["properties"]
167
287
  data["properties"] = _stored(str(node["type"]), given)
288
+ var below: Array[String] = node["children"]
289
+ # Counted rather than left as an empty list, which reads as a node with no children.
168
290
  if depth >= 0 and current_depth >= depth:
291
+ if not below.is_empty():
292
+ data["childrenNotListed"] = below.size()
169
293
  return data
170
- for child: String in node["children"]:
294
+ for child: String in below:
171
295
  children.append(_entry(nodes, child, include_properties, depth, current_depth + 1))
172
296
  return data
173
297
 
@@ -248,7 +248,8 @@ static func entries(lines: PackedStringArray) -> Dictionary:
248
248
 
249
249
 
250
250
  ## Each property the main resource of [param after] sets differently from [param before], leaving
251
- ## out [param asked], as [name, whether before sets it, the text before gives it].
251
+ ## out [param asked], as [name, whether before sets it, the text before gives it, the text after
252
+ ## gives it or null].
252
253
  static func main_changes(before: String, after: String, asked: Dictionary) -> Array[Array]:
253
254
  var was: Dictionary = _main_entries(before)
254
255
  var now: Dictionary = _main_entries(after)
@@ -259,10 +260,25 @@ static func main_changes(before: String, after: String, asked: Dictionary) -> Ar
259
260
  var name: String = str(key)
260
261
  if asked.has(name) or was.get(name) == now.get(name):
261
262
  continue
262
- changed.append([name, was.has(name), str(was.get(name, ""))])
263
+ changed.append([name, was.has(name), str(was.get(name, "")), now.get(name)])
263
264
  return changed
264
265
 
265
266
 
267
+ ## Whether [param was] and [param now], the texts two files give a property or null where one sets
268
+ ## nothing, read as the same value, nothing standing for [param default]. A file written by hand
269
+ ## spells a value its own way, 1 for 1.0, and may set a default, which the saver leaves out.
270
+ static func reads_the_same(was: Variant, now: Variant, default: Variant) -> bool:
271
+ var values: Array = []
272
+ for text: Variant in [was, now]:
273
+ if text == null:
274
+ values.append(default)
275
+ elif "Resource(" in str(text):
276
+ return false
277
+ else:
278
+ values.append(str_to_var(str(text)))
279
+ return PropertyValues.same(values[0], values[1])
280
+
281
+
266
282
  static func _main_entries(text: String) -> Dictionary:
267
283
  var main: Dictionary = sections(text).get("resource", {})
268
284
  return entries_of(main)
@@ -298,7 +314,8 @@ static func scripted_sections(text: String) -> Dictionary:
298
314
 
299
315
 
300
316
  ## Each property an embedded resource of [param after] sets differently from [param before], as
301
- ## [its id, name, whether before sets it, the text before gives it]. Only those with a script that
317
+ ## [its id, name, whether before sets it, the text before gives it, the text after gives it or
318
+ ## null]. Only those with a script that
302
319
  ## is not a tool are compared, since only a script runs differently here, and those in
303
320
  ## [param reached_ids], which the tool changed, are left out.
304
321
  static func embedded_changes(before: String, after: String, reached_ids: Dictionary) -> Array[Array]:
@@ -321,7 +338,7 @@ static func embedded_changes(before: String, after: String, reached_ids: Diction
321
338
  for entry: Variant in all:
322
339
  var name: String = str(entry)
323
340
  if was.get(name) != now.get(name):
324
- changed.append([id, name, was.has(name), str(was.get(name, ""))])
341
+ changed.append([id, name, was.has(name), str(was.get(name, "")), now.get(name)])
325
342
  return changed
326
343
 
327
344
 
@@ -345,11 +362,15 @@ static func settle_saved(
345
362
  var embedded: Array[Array] = []
346
363
  for found: Array in embedded_changes(before, after, reached_ids):
347
364
  var holder: Resource = by_id.get(found[0])
365
+ if holder != null and _unchanged(holder, found.slice(1)):
366
+ continue
348
367
  if found[2] or holder == null or not at_default(holder, str(found[1])):
349
368
  embedded.append(found)
350
369
  var top: Array[Array] = []
351
370
  if main != null:
352
371
  for found: Array in main_changes(before, after, asked):
372
+ if _unchanged(main, found):
373
+ continue
353
374
  if found[1] or not at_default(main, str(found[0])):
354
375
  top.append(found)
355
376
  if embedded.is_empty() and top.is_empty():
@@ -382,6 +403,15 @@ static func settle_saved(
382
403
  return ""
383
404
 
384
405
 
406
+ ## Whether the saved text gives a property of [param object] what the file did, spelt another way.
407
+ ## [param change] is [name, whether the file set it, the text it gave, the text after or null].
408
+ static func _unchanged(object: Object, change: Array) -> bool:
409
+ var name: String = change[0]
410
+ var was_set: bool = change[1]
411
+ var was: Variant = change[2] if was_set else null
412
+ return reads_the_same(was, change[3], default_of(object, name))
413
+
414
+
385
415
  ## Every embedded resource [param values] reach, at any depth, by its id in the file.
386
416
  static func embedded_in(values: Array, found: Dictionary) -> Dictionary:
387
417
  for value: Variant in values: