breakpoint-mcp 1.28.0 → 1.72.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/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +248 -13
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
- package/dist/annotations.js +3 -3
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +2 -2
- package/dist/csdap.js +88 -6
- package/dist/dap.js +172 -10
- package/dist/index.js +4 -4
- package/dist/path-cohort.js +177 -0
- package/dist/paths.js +111 -0
- package/dist/recipes.js +1 -1
- package/dist/schemas.js +49 -5
- package/dist/tools/assetgen.js +17 -1
- package/dist/tools/backend.js +18 -1
- package/dist/tools/cli.js +41 -4
- package/dist/tools/csdap.js +132 -8
- package/dist/tools/cslsp.js +110 -5
- package/dist/tools/dap.js +316 -59
- package/dist/tools/editor/audio.js +16 -2
- package/dist/tools/editor/common.js +39 -1
- package/dist/tools/editor/filesystem.js +23 -3
- package/dist/tools/editor/introspection.js +72 -1
- package/dist/tools/editor/node.js +7 -2
- package/dist/tools/editor/particles.js +7 -2
- package/dist/tools/editor/project_input_test.js +16 -2
- package/dist/tools/editor/resource.js +54 -7
- package/dist/tools/editor/scene.js +31 -4
- package/dist/tools/editor/shader.js +19 -3
- package/dist/tools/editor/spatial.js +16 -1
- package/dist/tools/editor/tiles.js +16 -1
- package/dist/tools/editor/ui.js +29 -2
- package/dist/tools/editor.js +20 -13
- package/dist/tools/knowledge.js +14 -1
- package/dist/tools/lsp-common.js +29 -0
- package/dist/tools/lsp.js +87 -8
- package/dist/tools/netcode.js +47 -4
- package/dist/tools/processes.js +14 -2
- package/dist/tools/runtime.js +49 -9
- package/dist/tools/tabletop.js +173 -7
- package/dist/tools/vcs.js +138 -22
- package/dist/toolsets.js +2 -2
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The MCP **host** for [Breakpoint MCP](https://github.com/jlivingston-Cipher/godot-breakpoint-mcp) —
|
|
4
4
|
a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the
|
|
5
5
|
Godot game engine to AI coding assistants across four planes: headless CLI, the live
|
|
6
|
-
editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **
|
|
6
|
+
editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **291 tools
|
|
7
7
|
+ 6 MCP resources**, built against the stable `@modelcontextprotocol/sdk` 1.x API.
|
|
8
8
|
Developed and tested with **Claude**; because MCP is an open protocol, other clients can
|
|
9
9
|
connect too (currently untested — reports welcome).
|
|
@@ -7,7 +7,7 @@ extends RefCounted
|
|
|
7
7
|
## wrapped in the EditorUndoRedoManager so a human can Ctrl-Z anything the assistant did.
|
|
8
8
|
|
|
9
9
|
const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
|
|
10
|
-
const ADDON_VERSION := "1.9.
|
|
10
|
+
const ADDON_VERSION := "1.9.8"
|
|
11
11
|
|
|
12
12
|
var _plugin: EditorPlugin
|
|
13
13
|
|
|
@@ -317,6 +317,10 @@ func dispatch(method: String, params: Dictionary) -> Dictionary:
|
|
|
317
317
|
return _ok(_selection_get())
|
|
318
318
|
"selection.set":
|
|
319
319
|
return _selection_set(params)
|
|
320
|
+
"main_screen.get":
|
|
321
|
+
return _main_screen_get()
|
|
322
|
+
"main_screen.set":
|
|
323
|
+
return _main_screen_set(params)
|
|
320
324
|
"classdb.get_class":
|
|
321
325
|
return _classdb_get_class(params)
|
|
322
326
|
"classdb.reference":
|
|
@@ -683,6 +687,119 @@ func _selection_set(params: Dictionary) -> Dictionary:
|
|
|
683
687
|
return _ok({"selection": applied})
|
|
684
688
|
|
|
685
689
|
|
|
690
|
+
# ------------------------------------------------- main-screen tab ----------
|
|
691
|
+
# Godot keeps the INACTIVE main-screen tab's viewport alive at its minimum size,
|
|
692
|
+
# so screenshot_editor on the wrong tab "succeeds" and returns a 2x2 placeholder.
|
|
693
|
+
# The host has guarded that since #139 by rejecting anything under 8px, but the
|
|
694
|
+
# error could only tell the caller to switch tabs by hand -- there was no tool to
|
|
695
|
+
# do it with, which is a dead end for the assistant this addon exists to serve.
|
|
696
|
+
#
|
|
697
|
+
# Tab names are NOT hardcoded anywhere here. The engine reports them, they differ
|
|
698
|
+
# between versions (Godot 4.6 added "Game"), and a caller that asks for one that
|
|
699
|
+
# does not exist gets the live list back in the error. `_main_screen_set` matches
|
|
700
|
+
# case-insensitively so "2d" works as well as the engine's own "2D".
|
|
701
|
+
#
|
|
702
|
+
# Both EditorInterface methods are reached through call()/has_method rather than
|
|
703
|
+
# literally, for the reason spelled out on _scene_close: a literal call to a
|
|
704
|
+
# method the running engine lacks fails at PARSE time and takes the WHOLE addon
|
|
705
|
+
# down on that version, not just this tool. get_editor_main_screen is 4.1+ and
|
|
706
|
+
# set_main_screen_editor is 4.0+, so both should be present across this repo's
|
|
707
|
+
# 4.3/4.5/4.7 matrix -- the guard is here so that being wrong about that degrades
|
|
708
|
+
# to one tool reporting `unsupported` instead of the plugin failing to load.
|
|
709
|
+
func _main_screen_root() -> Node:
|
|
710
|
+
if not EditorInterface.has_method("get_editor_main_screen"):
|
|
711
|
+
return null
|
|
712
|
+
return EditorInterface.call("get_editor_main_screen")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# The container's children do NOT carry the tab names. Measured on 4.7, the five
|
|
716
|
+
# children are named @CanvasItemEditor@10149, @Node3DEditor@10909, two
|
|
717
|
+
# @WindowWrapper@... and @EditorAssetLibrary@... -- Godot's auto-generated node
|
|
718
|
+
# names, which `set_main_screen_editor` does not accept. The tab name it DOES
|
|
719
|
+
# accept ("2D", "3D", "Script", "Game", "AssetLib") is the EditorPlugin's name,
|
|
720
|
+
# which is not on the control. So the label is derived from the control's class:
|
|
721
|
+
const _MAIN_SCREEN_LABELS := {
|
|
722
|
+
"CanvasItemEditor": "2D",
|
|
723
|
+
"Node3DEditor": "3D",
|
|
724
|
+
"ScriptEditor": "Script",
|
|
725
|
+
"GameView": "Game",
|
|
726
|
+
"EditorAssetLibrary": "AssetLib",
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
func _main_screen_label(c: Node) -> String:
|
|
731
|
+
var cls := c.get_class()
|
|
732
|
+
if _MAIN_SCREEN_LABELS.has(cls):
|
|
733
|
+
return String(_MAIN_SCREEN_LABELS[cls])
|
|
734
|
+
# Script and Game are wrapped in a WindowWrapper (the "make floating" feature),
|
|
735
|
+
# so the identifying class is one level down. Measured: ['Window','ScriptEditor']
|
|
736
|
+
# and ['Window','GameView'].
|
|
737
|
+
for k in c.get_children():
|
|
738
|
+
var kcls := k.get_class()
|
|
739
|
+
if _MAIN_SCREEN_LABELS.has(kcls):
|
|
740
|
+
return String(_MAIN_SCREEN_LABELS[kcls])
|
|
741
|
+
# A third-party EditorPlugin main screen, or a future built-in this map predates.
|
|
742
|
+
# Its real tab name is the plugin's get_plugin_name(), which is not reachable from
|
|
743
|
+
# here -- so report something honest rather than inventing one. set_main_screen_editor
|
|
744
|
+
# is still given the caller's string verbatim, so such a tab remains switchable by
|
|
745
|
+
# name even though this list cannot spell it.
|
|
746
|
+
var nm := String(c.name)
|
|
747
|
+
return cls if nm.begins_with("@") else nm
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
func _main_screen_state() -> Dictionary:
|
|
751
|
+
# The active tab is the one visible child; the editor shows exactly one at a time.
|
|
752
|
+
# Reported as null rather than guessed if somehow none is visible.
|
|
753
|
+
var mains := _main_screen_root()
|
|
754
|
+
var available: Array = []
|
|
755
|
+
var active: Variant = null
|
|
756
|
+
if mains == null:
|
|
757
|
+
return {"active": null, "available": available}
|
|
758
|
+
for c in mains.get_children():
|
|
759
|
+
var label := _main_screen_label(c)
|
|
760
|
+
available.append(label)
|
|
761
|
+
if c is Control and (c as Control).visible:
|
|
762
|
+
active = label
|
|
763
|
+
return {"active": active, "available": available}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
func _main_screen_get() -> Dictionary:
|
|
767
|
+
if _main_screen_root() == null:
|
|
768
|
+
return _err("unsupported", "main_screen_get requires EditorInterface.get_editor_main_screen (Godot 4.1+)")
|
|
769
|
+
return _ok(_main_screen_state())
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
func _main_screen_set(params: Dictionary) -> Dictionary:
|
|
773
|
+
if not EditorInterface.has_method("set_main_screen_editor"):
|
|
774
|
+
return _err("unsupported", "main_screen_set requires EditorInterface.set_main_screen_editor")
|
|
775
|
+
var state := _main_screen_state()
|
|
776
|
+
var available: Array = state.get("available", [])
|
|
777
|
+
var want := String(params.get("name", ""))
|
|
778
|
+
if want == "":
|
|
779
|
+
return _err("bad_params", "name is required (one of: %s)" % ", ".join(PackedStringArray(available)))
|
|
780
|
+
# Resolve case-insensitively against the derived labels so "2d" works, but fall
|
|
781
|
+
# back to the caller's string verbatim when nothing matches. The label map cannot
|
|
782
|
+
# spell a third-party plugin's main screen (see _main_screen_label), and the
|
|
783
|
+
# engine's own lookup can -- so a name this addon does not recognise is still
|
|
784
|
+
# WORTH TRYING rather than rejected here.
|
|
785
|
+
var target := want
|
|
786
|
+
for nm in available:
|
|
787
|
+
if String(nm).to_lower() == want.to_lower():
|
|
788
|
+
target = String(nm)
|
|
789
|
+
break
|
|
790
|
+
EditorInterface.call("set_main_screen_editor", target)
|
|
791
|
+
# Verify by READ-BACK, not by trusting the call. set_main_screen_editor returns
|
|
792
|
+
# nothing and silently does nothing for a name it does not know, so this is the
|
|
793
|
+
# only way to tell a successful switch from a typo -- and it is what makes the
|
|
794
|
+
# verbatim fallback above safe. It also means the caller's next move (usually a
|
|
795
|
+
# screenshot) acts on what the editor actually did.
|
|
796
|
+
var after := _main_screen_state()
|
|
797
|
+
var now := String(after.get("active", ""))
|
|
798
|
+
if now.to_lower() != target.to_lower():
|
|
799
|
+
return _err("not_found", "No main-screen tab named \"%s\" — the editor is still on \"%s\". Available: %s" % [want, now, ", ".join(PackedStringArray(available))])
|
|
800
|
+
return _ok({"active": after.get("active"), "available": after.get("available"), "requested": target})
|
|
801
|
+
|
|
802
|
+
|
|
686
803
|
func _classdb_get_class(params: Dictionary) -> Dictionary:
|
|
687
804
|
var cls := String(params.get("class_name", ""))
|
|
688
805
|
if cls == "" or not ClassDB.class_exists(cls):
|
|
@@ -1294,10 +1411,71 @@ func _scene_get_dependencies(params: Dictionary) -> Dictionary:
|
|
|
1294
1411
|
return _err("bad_params", "Current scene has no saved path yet")
|
|
1295
1412
|
if not ResourceLoader.exists(target):
|
|
1296
1413
|
return _err("not_found", "Scene not found: %s" % target)
|
|
1414
|
+
# 🔴 THE ENTRY GODOT HANDS BACK IS NOT A PATH, AND FOR SOME DEPENDENCIES IT IS THE
|
|
1415
|
+
# ONLY SPELLING THAT DOES NOT LOAD (session 169 §5). Measured on 4.7 against the
|
|
1416
|
+
# example project, with controls:
|
|
1417
|
+
#
|
|
1418
|
+
# res://main.tscn -> "uid://ccgi4n26nbyku::::res://player.gd" (2 segments)
|
|
1419
|
+
# res://demo/demo.tscn -> "res://demo/demo_snowman.gd" (1 segment)
|
|
1420
|
+
#
|
|
1421
|
+
# The shape is HETEROGENEOUS — it depends on whether the dependency has a `.uid`
|
|
1422
|
+
# sidecar — so `dependencies` was an array a caller could not parse without already
|
|
1423
|
+
# knowing the rule. Fed to resource_load verbatim, the two-segment form answers
|
|
1424
|
+
# `not_found`, while BOTH of its halves load fine on their own. The tool was handing
|
|
1425
|
+
# out the one spelling that works with nothing.
|
|
1426
|
+
#
|
|
1427
|
+
# This is #181's defect in a read-only tool: a value echoed from the engine and
|
|
1428
|
+
# presented as an answer nobody had measured. Same fix as #181 — the field is made to
|
|
1429
|
+
# mean what its description says, and nothing is destroyed: the engine's own encoding
|
|
1430
|
+
# is preserved verbatim under `dependencies_raw`.
|
|
1431
|
+
#
|
|
1432
|
+
# 🔴 THE FALLBACK NEVER DROPS AN ENTRY. An unrecognised shape (no res:// or user://
|
|
1433
|
+
# segment) is passed through whole rather than skipped: a splitter that silently
|
|
1434
|
+
# discards what it does not understand would shrink the dependency list and look
|
|
1435
|
+
# cleaner for it, which is this session's entire subject.
|
|
1297
1436
|
var deps: Array = []
|
|
1437
|
+
var raw: Array = []
|
|
1438
|
+
var uids: Array = []
|
|
1298
1439
|
for d in ResourceLoader.get_dependencies(target):
|
|
1299
|
-
|
|
1300
|
-
|
|
1440
|
+
var entry := String(d)
|
|
1441
|
+
var split := split_dependency_entry(entry)
|
|
1442
|
+
raw.append(entry)
|
|
1443
|
+
deps.append(split["path"])
|
|
1444
|
+
uids.append(split["uid"])
|
|
1445
|
+
return _ok({
|
|
1446
|
+
"path": target,
|
|
1447
|
+
"dependencies": deps,
|
|
1448
|
+
"dependencies_raw": raw,
|
|
1449
|
+
"dependency_uids": uids,
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
## Split one ResourceLoader.get_dependencies entry into its loadable path and its UID.
|
|
1454
|
+
##
|
|
1455
|
+
## 🔴 A SEPARATE, PUBLIC FUNCTION ON PURPOSE. Inlined in the loop, two of its branches
|
|
1456
|
+
## were unreachable from any scene the example project contains — a mutation sweep proved
|
|
1457
|
+
## it (169 §7): making the splitter DROP unrecognised entries, and matching the UID prefix
|
|
1458
|
+
## as loosely as `begins_with("u")`, both SURVIVED with the suite fully green, because no
|
|
1459
|
+
## fixture produces an entry that exercises either path. The comment claiming "the
|
|
1460
|
+
## fallback never drops an entry" was itself an unasserted claim, which is this session's
|
|
1461
|
+
## whole subject. Pulled out here, every branch takes a synthetic input in one cheap
|
|
1462
|
+
## hermetic test.
|
|
1463
|
+
##
|
|
1464
|
+
## Returns {"path": String, "uid": String}. `uid` is "" when the entry carries none.
|
|
1465
|
+
## An entry with no recognisable path segment is returned WHOLE as the path: dropping it
|
|
1466
|
+
## would shrink the dependency list and look tidier for it.
|
|
1467
|
+
static func split_dependency_entry(entry: String) -> Dictionary:
|
|
1468
|
+
var found_path := ""
|
|
1469
|
+
var found_uid := ""
|
|
1470
|
+
for s in entry.split("::::"):
|
|
1471
|
+
var seg := String(s)
|
|
1472
|
+
if seg.begins_with("res://") or seg.begins_with("user://"):
|
|
1473
|
+
if found_path == "":
|
|
1474
|
+
found_path = seg
|
|
1475
|
+
elif seg.begins_with("uid://"):
|
|
1476
|
+
if found_uid == "":
|
|
1477
|
+
found_uid = seg
|
|
1478
|
+
return {"path": found_path if found_path != "" else entry, "uid": found_uid}
|
|
1301
1479
|
|
|
1302
1480
|
|
|
1303
1481
|
func _scene_pack(params: Dictionary) -> Dictionary:
|
|
@@ -1624,6 +1802,18 @@ func _resource_get_import_settings(params: Dictionary) -> Dictionary:
|
|
|
1624
1802
|
var path := String(params.get("path", ""))
|
|
1625
1803
|
if path == "":
|
|
1626
1804
|
return _err("bad_params", "Missing 'path'")
|
|
1805
|
+
# 🔴 168 (166 §5 D3), MEASURED against a live 4.7 editor: this used to test ONLY for
|
|
1806
|
+
# the sidecar, so `res://player.gd` (a real file with no .import) and
|
|
1807
|
+
# `res://no_such_file.png` (nothing at all) returned BYTE-IDENTICAL replies —
|
|
1808
|
+
# {"imported": false, "importer": "", "settings": {}} — and a caller had no channel
|
|
1809
|
+
# to tell "not imported" from "not there". A directory answered the same way.
|
|
1810
|
+
# The vocabulary here is NOT invented: resource_load and resource_get_property were
|
|
1811
|
+
# both probed for a missing path AND for a directory, and both already answer
|
|
1812
|
+
# `not_found` / "Resource not found: %s" for each. This joins that convention rather
|
|
1813
|
+
# than adding a third one. `imported: false` now means what it says — the file is
|
|
1814
|
+
# really there and really has no .import sidecar.
|
|
1815
|
+
if not FileAccess.file_exists(path):
|
|
1816
|
+
return _err("not_found", "Resource not found: %s" % path)
|
|
1627
1817
|
var import_path := path + ".import"
|
|
1628
1818
|
if not FileAccess.file_exists(import_path):
|
|
1629
1819
|
return _ok({"path": path, "imported": false, "importer": "", "settings": {}})
|
|
@@ -1645,6 +1835,12 @@ func _resource_set_import_settings(params: Dictionary) -> Dictionary:
|
|
|
1645
1835
|
var path := String(params.get("path", ""))
|
|
1646
1836
|
if path == "":
|
|
1647
1837
|
return _err("bad_params", "Missing 'path'")
|
|
1838
|
+
# 🔴 168: the same lie as D3, one tool over. A path that does not exist was refused
|
|
1839
|
+
# with "No .import metadata for X (not an imported asset)" — a sentence that
|
|
1840
|
+
# describes a file which EXISTS and is not imported. Measured: a directory got that
|
|
1841
|
+
# message too. `not_imported` is now reserved for what it names.
|
|
1842
|
+
if not FileAccess.file_exists(path):
|
|
1843
|
+
return _err("not_found", "Resource not found: %s" % path)
|
|
1648
1844
|
var import_path := path + ".import"
|
|
1649
1845
|
if not FileAccess.file_exists(import_path):
|
|
1650
1846
|
return _err("not_imported", "No .import metadata for %s (not an imported asset)" % path)
|
|
@@ -1652,10 +1848,28 @@ func _resource_set_import_settings(params: Dictionary) -> Dictionary:
|
|
|
1652
1848
|
var e := cfg.load(import_path)
|
|
1653
1849
|
if e != OK:
|
|
1654
1850
|
return _err("load_failed", "Could not read import metadata: %s (%d)" % [import_path, e])
|
|
1851
|
+
# 🔴 168 (166 §5 D4), MEASURED: `applied` lists every key the CALLER SENT, and
|
|
1852
|
+
# `reimported` echoes the REQUEST parameter. Setting a key to the value it already
|
|
1853
|
+
# held reported {"reimported": true, "settings": ["compress/mode"]} with the
|
|
1854
|
+
# sidecar's bytes provably unmoved (sha256 before == after). A caller reading that
|
|
1855
|
+
# reply cannot tell a real edit from a ceremonial one.
|
|
1856
|
+
#
|
|
1857
|
+
# `changed` is the missing channel, and it is deliberately a channel rather than a
|
|
1858
|
+
# behaviour change: `settings: {}` with `reimport: true` is a legitimate FORCE-REIMPORT
|
|
1859
|
+
# idiom (the source file moved under Godot's feet), so the reimport still runs and
|
|
1860
|
+
# `reimported` still reports it honestly. What was missing was the ability to say
|
|
1861
|
+
# "and nothing in the metadata actually moved" — which is now `changed: []`.
|
|
1655
1862
|
var applied: Array = []
|
|
1863
|
+
var changed: Array = []
|
|
1656
1864
|
for key in params.get("settings", {}):
|
|
1657
|
-
|
|
1658
|
-
|
|
1865
|
+
var k := String(key)
|
|
1866
|
+
var new_value = Codec.decode(params["settings"][key])
|
|
1867
|
+
var had := cfg.has_section_key("params", k)
|
|
1868
|
+
var old_value = cfg.get_value("params", k) if had else null
|
|
1869
|
+
cfg.set_value("params", k, new_value)
|
|
1870
|
+
applied.append(k)
|
|
1871
|
+
if not had or old_value != new_value:
|
|
1872
|
+
changed.append(k)
|
|
1659
1873
|
e = cfg.save(import_path)
|
|
1660
1874
|
if e != OK:
|
|
1661
1875
|
return _err("save_failed", "Could not write import metadata (%d)" % e)
|
|
@@ -1663,7 +1877,7 @@ func _resource_set_import_settings(params: Dictionary) -> Dictionary:
|
|
|
1663
1877
|
if reimport:
|
|
1664
1878
|
var efs := EditorInterface.get_resource_filesystem()
|
|
1665
1879
|
efs.reimport_files(PackedStringArray([path]))
|
|
1666
|
-
return _ok({"path": path, "reimported": reimport, "settings": applied})
|
|
1880
|
+
return _ok({"path": path, "reimported": reimport, "settings": applied, "changed": changed})
|
|
1667
1881
|
|
|
1668
1882
|
|
|
1669
1883
|
# ----------------------------------------------- Group B: filesystem --------
|
|
@@ -1706,12 +1920,28 @@ func _filesystem_move(params: Dictionary) -> Dictionary:
|
|
|
1706
1920
|
var e := dir.rename(from_path, to_path)
|
|
1707
1921
|
if e != OK:
|
|
1708
1922
|
return _err("move_failed", "rename() returned %d" % e)
|
|
1923
|
+
# 🔴 173 §10.3 — THE THIRD CONFIRMED MEMBER OF THE ECHO-MISTAKEN-FOR-A-REPORT
|
|
1924
|
+
# FAMILY (#181, #183, 172 D5). `dir.rename()`'s RETURN WAS DISCARDED HERE and
|
|
1925
|
+
# `moved_import` was set to true whenever a sidecar merely EXISTED — so the field
|
|
1926
|
+
# described the REQUEST ("there was an .import and I called rename") rather than the
|
|
1927
|
+
# outcome. A sidecar rename that failed (destination taken, permissions, a
|
|
1928
|
+
# case-only rename on a case-insensitive filesystem) left an ORPHANED .import beside
|
|
1929
|
+
# a file that had moved, stripping the moved asset of its import settings on the next
|
|
1930
|
+
# scan — and the report said `moved_import: true`.
|
|
1931
|
+
#
|
|
1932
|
+
# THREE OUTCOMES, TWO BOOLEANS, BOTH DERIVED FROM THE RETURN CODE. "No sidecar" and
|
|
1933
|
+
# "a sidecar that would not move" are different events and the caller has to be able
|
|
1934
|
+
# to tell them apart; one boolean can only say "not moved" for both. The move itself
|
|
1935
|
+
# already succeeded, so a stranded sidecar is a PARTIAL, not a failure — returning
|
|
1936
|
+
# _err here would say nothing happened, which is the same lie in the other direction.
|
|
1709
1937
|
var moved_import := false
|
|
1938
|
+
var import_stranded := false
|
|
1710
1939
|
if is_file and FileAccess.file_exists(from_path + ".import"):
|
|
1711
|
-
dir.rename(from_path + ".import", to_path + ".import")
|
|
1712
|
-
moved_import =
|
|
1940
|
+
var ie := dir.rename(from_path + ".import", to_path + ".import")
|
|
1941
|
+
moved_import = ie == OK
|
|
1942
|
+
import_stranded = ie != OK
|
|
1713
1943
|
EditorInterface.get_resource_filesystem().scan()
|
|
1714
|
-
return _ok({"moved": to_path, "from": from_path, "moved_import": moved_import})
|
|
1944
|
+
return _ok({"moved": to_path, "from": from_path, "moved_import": moved_import, "import_stranded": import_stranded})
|
|
1715
1945
|
|
|
1716
1946
|
|
|
1717
1947
|
func _filesystem_create_dir(params: Dictionary) -> Dictionary:
|
|
@@ -1723,6 +1953,11 @@ func _filesystem_create_dir(params: Dictionary) -> Dictionary:
|
|
|
1723
1953
|
var e := DirAccess.make_dir_recursive_absolute(path)
|
|
1724
1954
|
if e != OK:
|
|
1725
1955
|
return _err("mkdir_failed", "make_dir_recursive_absolute() returned %d" % e)
|
|
1956
|
+
# 🔴 173 §10.3, same family, one step milder: `created` was the REQUESTED path echoed
|
|
1957
|
+
# back, and an OK return was never once confirmed against the filesystem. Re-read it,
|
|
1958
|
+
# so `created` names a directory that is THERE rather than one that was asked for.
|
|
1959
|
+
if not DirAccess.dir_exists_absolute(path):
|
|
1960
|
+
return _err("mkdir_failed", "make_dir_recursive_absolute() returned OK but %s does not exist" % path)
|
|
1726
1961
|
EditorInterface.get_resource_filesystem().scan()
|
|
1727
1962
|
return _ok({"created": path, "existed": false})
|
|
1728
1963
|
|
|
@@ -3275,7 +3510,7 @@ func _particles_set_texture(params: Dictionary) -> Dictionary:
|
|
|
3275
3510
|
if node == null:
|
|
3276
3511
|
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
3277
3512
|
if not (node is GPUParticles2D):
|
|
3278
|
-
return _err("unsupported", "%s has no texture
|
|
3513
|
+
return _err("unsupported", "%s has no texture: only GPUParticles2D has one, and GPUParticles3D draws meshes instead. Pass the path of a GPUParticles2D node." % node.name)
|
|
3279
3514
|
var tex_path := String(params.get("texture_path", ""))
|
|
3280
3515
|
if tex_path == "" or not ResourceLoader.exists(tex_path):
|
|
3281
3516
|
return _err("not_found", "Texture not found: %s" % tex_path)
|
|
@@ -3347,7 +3582,7 @@ func _shadermaterial_create(params: Dictionary) -> Dictionary:
|
|
|
3347
3582
|
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
3348
3583
|
var prop := _material_prop(node)
|
|
3349
3584
|
if prop == "":
|
|
3350
|
-
return _err("unsupported", "%s has no material slot
|
|
3585
|
+
return _err("unsupported", "%s has no material slot. Pass a node that has one: any CanvasItem exposes CanvasItem.material, any GeometryInstance3D exposes GeometryInstance3D.material_override." % node.name)
|
|
3351
3586
|
var mat := ShaderMaterial.new()
|
|
3352
3587
|
var shader_path := String(params.get("shader_path", ""))
|
|
3353
3588
|
if shader_path != "":
|
|
@@ -3375,7 +3610,7 @@ func _shadermaterial_set_shader(params: Dictionary) -> Dictionary:
|
|
|
3375
3610
|
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
3376
3611
|
var prop := _material_prop(node)
|
|
3377
3612
|
if prop == "":
|
|
3378
|
-
return _err("unsupported", "%s has no material slot" % node.name)
|
|
3613
|
+
return _err("unsupported", "%s has no material slot. Pass a node that has one: any CanvasItem exposes CanvasItem.material, any GeometryInstance3D exposes GeometryInstance3D.material_override." % node.name)
|
|
3379
3614
|
var cur = node.get(prop)
|
|
3380
3615
|
if not (cur is ShaderMaterial):
|
|
3381
3616
|
return _err("bad_type", "%s has no ShaderMaterial (create one first)" % node.name)
|
|
@@ -3403,7 +3638,7 @@ func _shadermaterial_set_param(params: Dictionary) -> Dictionary:
|
|
|
3403
3638
|
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
3404
3639
|
var prop := _material_prop(node)
|
|
3405
3640
|
if prop == "":
|
|
3406
|
-
return _err("unsupported", "%s has no material slot" % node.name)
|
|
3641
|
+
return _err("unsupported", "%s has no material slot. Pass a node that has one: any CanvasItem exposes CanvasItem.material, any GeometryInstance3D exposes GeometryInstance3D.material_override." % node.name)
|
|
3407
3642
|
var cur = node.get(prop)
|
|
3408
3643
|
if not (cur is ShaderMaterial):
|
|
3409
3644
|
return _err("bad_type", "%s has no ShaderMaterial (create one first)" % node.name)
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
name="Breakpoint MCP"
|
|
4
4
|
description="Exposes the live Godot editor and the running game to an MCP host so an AI assistant can drive them: scene/node/resource CRUD with full undo/redo, project settings, ClassDB, editor + in-game screenshots, and an in-game runtime bridge (live SceneTree, property get/set, method calls, signals, input injection, Performance monitors). Pairs with the breakpoint-mcp MCP host."
|
|
5
5
|
author="James Livingston"
|
|
6
|
-
version="1.9.
|
|
6
|
+
version="1.9.8"
|
|
7
7
|
script="plugin.gd"
|
|
@@ -35,6 +35,12 @@ const MONITORS := {
|
|
|
35
35
|
"time/process": Performance.TIME_PROCESS,
|
|
36
36
|
"time/physics_process": Performance.TIME_PHYSICS_PROCESS,
|
|
37
37
|
"memory/static": Performance.MEMORY_STATIC,
|
|
38
|
+
# Total live ObjectDB instances — Nodes, Resources and BARE Objects alike, which is
|
|
39
|
+
# what makes it the only monitor here that can see a leaked non-Node. Added session
|
|
40
|
+
# 153 so the _node_add `not_a_node` leak fixed in the same commit is REGRESSION-LOCKED
|
|
41
|
+
# in CI rather than merely proved once on a laptop: node-lifecycle.integration.mjs
|
|
42
|
+
# drives that branch in bulk and watches this number.
|
|
43
|
+
"object/count": Performance.OBJECT_COUNT,
|
|
38
44
|
"object/node_count": Performance.OBJECT_NODE_COUNT,
|
|
39
45
|
"object/resource_count": Performance.OBJECT_RESOURCE_COUNT,
|
|
40
46
|
"render/total_objects_drawn": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME,
|
|
@@ -456,7 +462,36 @@ func _emit_signal(params: Dictionary) -> Dictionary:
|
|
|
456
462
|
var call_args: Array = [sig]
|
|
457
463
|
for a in params.get("args", []):
|
|
458
464
|
call_args.append(Codec.decode(a))
|
|
459
|
-
|
|
465
|
+
# emit_signal RETURNS an Error, and discarding it made this tool answer
|
|
466
|
+
# {"emitted": true} for an emission the engine refused. The cause is an `args` count
|
|
467
|
+
# that does not match a CONNECTED callable's arity: the engine pushes its own
|
|
468
|
+
# "Method expected N argument(s), but called with M" into the GAME's log — not the
|
|
469
|
+
# caller's — and that callable never runs, so the caller fails at its NEXT assertion,
|
|
470
|
+
# arbitrarily far from the cause.
|
|
471
|
+
#
|
|
472
|
+
# 🔴 TWO non-OK codes, and only one of them is a failure. Measured identically on
|
|
473
|
+
# 4.3, 4.5 and 4.7:
|
|
474
|
+
#
|
|
475
|
+
# OK (0) a connected callable ran
|
|
476
|
+
# ERR_METHOD_NOT_FOUND (37) a callable IS connected and could not be invoked —
|
|
477
|
+
# the arity mismatch. This is the defect worth reporting.
|
|
478
|
+
# ERR_UNAVAILABLE (2) the signal exists but has NO connections at all.
|
|
479
|
+
# Emitting into the void is ordinary and must SUCCEED —
|
|
480
|
+
# rejecting it would break every game that emits a signal
|
|
481
|
+
# nothing happens to be listening to.
|
|
482
|
+
#
|
|
483
|
+
# 🔴 The honest consequence: arity is checkable ONLY when something is connected.
|
|
484
|
+
# With no listener, a wrong count also returns ERR_UNAVAILABLE (there is no callable
|
|
485
|
+
# whose arity could mismatch), so it is indistinguishable from a correct emission.
|
|
486
|
+
# That is a limit of what the engine reports, not something this tool can tighten,
|
|
487
|
+
# and the catalog says so rather than implying a guarantee that does not exist.
|
|
488
|
+
var err: int = node.callv("emit_signal", call_args)
|
|
489
|
+
if err != OK and err != ERR_UNAVAILABLE:
|
|
490
|
+
return _err(
|
|
491
|
+
"emit_failed",
|
|
492
|
+
"emit_signal(%s) with %d arg(s) failed: %s (%d). Check the count against the arity of the signal's connected callables."
|
|
493
|
+
% [sig, call_args.size() - 1, error_string(err), err]
|
|
494
|
+
)
|
|
460
495
|
return _ok({"emitted": true})
|
|
461
496
|
|
|
462
497
|
|
|
@@ -466,6 +501,14 @@ func _inject_input(params: Dictionary) -> Dictionary:
|
|
|
466
501
|
match kind:
|
|
467
502
|
"action":
|
|
468
503
|
var action := String(ev.get("action", ""))
|
|
504
|
+
# An action the InputMap does not know is NOT injectable. Input.action_press
|
|
505
|
+
# pushes its own engine error and returns, so without this guard the reply is
|
|
506
|
+
# {"injected": true} for a typo'd action name and the caller's NEXT assertion is
|
|
507
|
+
# where the failure surfaces — as far from the cause as it is possible to get.
|
|
508
|
+
# Found live, session 153, by the probe that first pointed this tool at a real
|
|
509
|
+
# InputMap. Covers both operands: an absent "action" key arrives here as "".
|
|
510
|
+
if not InputMap.has_action(action):
|
|
511
|
+
return _err("bad_action", "No such InputMap action: %s" % action)
|
|
469
512
|
if bool(ev.get("pressed", true)):
|
|
470
513
|
Input.action_press(action, float(ev.get("strength", 1.0)))
|
|
471
514
|
else:
|
|
@@ -891,6 +934,16 @@ func _node_add(params: Dictionary) -> Dictionary:
|
|
|
891
934
|
return _err("bad_type", "Cannot instantiate class: %s" % type_name)
|
|
892
935
|
var obj: Variant = ClassDB.instantiate(type_name)
|
|
893
936
|
if not (obj is Node):
|
|
937
|
+
# ClassDB.instantiate hands back an UNOWNED instance. A RefCounted subclass
|
|
938
|
+
# (Resource, Image, ...) releases itself when this reference goes out of scope;
|
|
939
|
+
# a bare Object does NOT, so returning here without freeing leaked one instance
|
|
940
|
+
# per call — which the engine reports only at exit, as "N ObjectDB instances
|
|
941
|
+
# were leaked". Proved session 152, measured session 153 at 51 leaked over 51
|
|
942
|
+
# calls and 0 with this branch in place.
|
|
943
|
+
# The RefCounted arm MUST be excluded: free() on a RefCounted is itself an error,
|
|
944
|
+
# which is why the safe-looking `Resource` case hid this for so long.
|
|
945
|
+
if obj is Object and not (obj is RefCounted):
|
|
946
|
+
(obj as Object).free()
|
|
894
947
|
return _err("not_a_node", "%s is not a Node" % type_name)
|
|
895
948
|
child = obj
|
|
896
949
|
if child == null:
|
package/dist/annotations.js
CHANGED
|
@@ -8,7 +8,7 @@ const READ_ONLY = [
|
|
|
8
8
|
"gd_declaration", "gd_definition", "gd_diagnostics", "gd_document_color", "gd_document_highlight",
|
|
9
9
|
"gd_document_link", "gd_document_symbols", "gd_folding_ranges", "gd_formatting", "gd_hover", "gd_implementation",
|
|
10
10
|
"gd_references", "gd_semantic_tokens", "gd_signature_help", "gd_type_definition", "gd_workspace_symbols",
|
|
11
|
-
"godot_output", "godot_version", "inputmap_list", "node_find", "node_get_children", "node_get_path",
|
|
11
|
+
"godot_output", "godot_version", "inputmap_list", "main_screen_get", "node_find", "node_get_children", "node_get_path",
|
|
12
12
|
"node_get_property", "node_list_groups", "node_list_properties", "project_get_info", "project_get_setting",
|
|
13
13
|
"project_list_settings", "project_search", "resource_get_import_settings", "resource_get_property",
|
|
14
14
|
"resource_load", "runtime_anim_get_state", "runtime_assert_node_state", "runtime_assert_perf",
|
|
@@ -63,7 +63,7 @@ const IDEMPOTENT = [
|
|
|
63
63
|
"gd_references", "gd_rename", "gd_semantic_tokens", "gd_signature_help", "gd_type_definition",
|
|
64
64
|
"gd_workspace_symbols", "godot_export", "godot_import", "godot_output", "godot_stop", "godot_version",
|
|
65
65
|
"inputmap_add_action", "inputmap_erase_action", "inputmap_list", "joint_set_bodies", "leaderboard_scaffold",
|
|
66
|
-
"mesh_set_surface_material", "mp_scaffold_lobby", "mp_set_authority", "mp_setup_enet_peer",
|
|
66
|
+
"main_screen_get", "main_screen_set", "mesh_set_surface_material", "mp_scaffold_lobby", "mp_set_authority", "mp_setup_enet_peer",
|
|
67
67
|
"mp_setup_webrtc_peer", "mp_wire_rpc", "node_add_to_group", "node_change_type", "node_delete", "node_find",
|
|
68
68
|
"node_get_children", "node_get_path", "node_get_property", "node_list_groups", "node_list_properties",
|
|
69
69
|
"node_move_child", "node_remove_from_group", "node_rename", "node_reparent", "node_set_editable_instance",
|
|
@@ -131,7 +131,7 @@ export const ALL_ANNOTATED = [
|
|
|
131
131
|
"godot_launch_editor", "godot_output", "godot_run_headless_script", "godot_run_managed", "godot_run_project",
|
|
132
132
|
"godot_stop", "godot_version", "inputmap_add_action", "inputmap_add_event", "inputmap_erase_action",
|
|
133
133
|
"inputmap_list", "interact_add_drop_zone", "interact_make_draggable", "joint_create", "joint_set_bodies",
|
|
134
|
-
"leaderboard_scaffold", "light_create", "mesh_set_surface_material", "meshinstance_create", "mp_add_spawner",
|
|
134
|
+
"leaderboard_scaffold", "light_create", "main_screen_get", "main_screen_set", "mesh_set_surface_material", "meshinstance_create", "mp_add_spawner",
|
|
135
135
|
"mp_add_synchronizer", "mp_scaffold_lobby", "mp_set_authority", "mp_setup_enet_peer", "mp_setup_webrtc_peer",
|
|
136
136
|
"mp_wire_rpc", "navagent_configure", "navregion_create", "node_add", "node_add_to_group", "node_call_method",
|
|
137
137
|
"node_change_type", "node_delete", "node_duplicate", "node_find", "node_get_children", "node_get_path",
|
package/dist/cli/doctor.js
CHANGED
|
@@ -195,7 +195,7 @@ export function checkCapabilities(config) {
|
|
|
195
195
|
status: "ok",
|
|
196
196
|
severity: "info",
|
|
197
197
|
detail: dropped.length === 0
|
|
198
|
-
? `${state} — full
|
|
198
|
+
? `${state} — full 291-tool surface`
|
|
199
199
|
: `${state} (secure default) — ${dropped.length} higher-trust tool(s) dropped from the surface`,
|
|
200
200
|
hint: dropped.length === 0
|
|
201
201
|
? undefined
|
package/dist/cli/init.js
CHANGED
|
@@ -279,7 +279,7 @@ export async function runInit(argv, deps = {}) {
|
|
|
279
279
|
// Trust groups — surface the secure default so even a bare `init` teaches it.
|
|
280
280
|
say("");
|
|
281
281
|
if (privilegedGroups) {
|
|
282
|
-
say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full
|
|
282
|
+
say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 291-tool surface loads.`);
|
|
283
283
|
}
|
|
284
284
|
else {
|
|
285
285
|
say("Higher-trust tool groups are OFF by default (the secure default). The assistant loads the");
|
package/dist/cli/tools.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* container, and on a machine with no Godot installed.
|
|
18
18
|
*
|
|
19
19
|
* Two surfaces are reported, because the difference is the security story:
|
|
20
|
-
* • `full` — all
|
|
21
|
-
* • `secure-default` —
|
|
20
|
+
* • `full` — all 291 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
|
|
21
|
+
* • `secure-default` — 278, what an untouched install actually advertises
|
|
22
22
|
* `--surface` picks which one populates `tools[]`; both counts are always in the
|
|
23
23
|
* header so a consumer cannot report one as the other by accident.
|
|
24
24
|
*/
|
package/dist/csdap.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { DapError } from "./dap.js";
|
|
3
3
|
import { OverdueLedger } from "./late-reply.js";
|
|
4
|
+
/**
|
|
5
|
+
* How long `start()` waits for the entry `stopped` event when `stopAtEntry` was
|
|
6
|
+
* requested. Bounded so an adapter that ignores stop-on-entry reports `running`
|
|
7
|
+
* rather than hanging the launch. Measured: netcoredbg emits it well inside a
|
|
8
|
+
* second on a local program, after its `configurationDone` response.
|
|
9
|
+
*/
|
|
10
|
+
const ENTRY_STOP_WAIT_MS = 10000;
|
|
4
11
|
/**
|
|
5
12
|
* Minimal DAP client for the C#/.NET debugging plane (D4 C3) — the debugger
|
|
6
13
|
* analogue of the C2 `CsLspClient`. Transport-agnostic: it drives any
|
|
@@ -40,6 +47,12 @@ export class CsDapClient extends EventEmitter {
|
|
|
40
47
|
watches = [];
|
|
41
48
|
lastStartMode = null;
|
|
42
49
|
lastStartArgs = null;
|
|
50
|
+
/**
|
|
51
|
+
* The launch/attach rejection, when one arrives. Set by `start()`; read there and
|
|
52
|
+
* kept afterwards so a rejection that lands late is still visible rather than
|
|
53
|
+
* being emitted as an unlistened `error` event (which throws).
|
|
54
|
+
*/
|
|
55
|
+
startFailure = null;
|
|
43
56
|
capabilities = null;
|
|
44
57
|
state = "disconnected";
|
|
45
58
|
lastStoppedThreadId = null;
|
|
@@ -229,7 +242,23 @@ export class CsDapClient extends EventEmitter {
|
|
|
229
242
|
await this.applyBreakpoints(path).catch(() => undefined);
|
|
230
243
|
}
|
|
231
244
|
}
|
|
232
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* Full handshake: initialize → launch/attach → (breakpoints) → configurationDone.
|
|
247
|
+
*
|
|
248
|
+
* 🔴 The handshake used to declare success unconditionally. Measured against a
|
|
249
|
+
* real netcoredbg 3.2.0-1092 with `program: "/no/such/binary"`:
|
|
250
|
+
*
|
|
251
|
+
* >> launch << success=true
|
|
252
|
+
* >> configurationDone << success=false
|
|
253
|
+
* "Failed command 'configurationDone' : 0x80070002"
|
|
254
|
+
*
|
|
255
|
+
* `0x80070002` is ERROR_FILE_NOT_FOUND — the adapter DOES report the failure, and it
|
|
256
|
+
* reports it on `configurationDone`, which was `.catch(() => undefined)`-swallowed
|
|
257
|
+
* immediately before `state = "running"`. So `cs_dbg_launch` answered
|
|
258
|
+
* `isError:false state:"running"` for a session that never existed, and every tool
|
|
259
|
+
* afterwards failed with a bare hex code against a phantom session. The same held
|
|
260
|
+
* for `program: ""` and for `cs_dbg_attach` on a process id that does not exist.
|
|
261
|
+
*/
|
|
233
262
|
async start(mode, args) {
|
|
234
263
|
this.lastStartMode = mode;
|
|
235
264
|
this.lastStartArgs = args;
|
|
@@ -247,14 +276,67 @@ export class CsDapClient extends EventEmitter {
|
|
|
247
276
|
this.state = "initialized";
|
|
248
277
|
// Send launch/attach but don't await it yet — many adapters (netcoredbg included)
|
|
249
278
|
// only resolve it after configurationDone.
|
|
250
|
-
|
|
279
|
+
//
|
|
280
|
+
// 🔴 A rejection used to be routed to `this.emit("error", err)`. Nothing in the
|
|
281
|
+
// host registers an `error` listener, and an unlistened `error` emit on an
|
|
282
|
+
// EventEmitter is an UNCAUGHT THROW — a failing launch could take the MCP server
|
|
283
|
+
// down. It is captured instead, and recorded on the client so a rejection that
|
|
284
|
+
// lands after `start()` has returned still puts the session in `terminated`
|
|
285
|
+
// rather than leaving it reading `running`.
|
|
286
|
+
this.startFailure = null;
|
|
287
|
+
const startReq = this.request(mode, args).then(() => undefined, (err) => {
|
|
288
|
+
this.startFailure = err;
|
|
289
|
+
this.configured = false;
|
|
290
|
+
this.state = "terminated";
|
|
291
|
+
// A DISTINCT event name, deliberately: `error` is special-cased by
|
|
292
|
+
// EventEmitter and throws when unlistened. This one is safe to ignore.
|
|
293
|
+
this.emit("start_failed", err);
|
|
294
|
+
});
|
|
251
295
|
await onInit;
|
|
252
296
|
await this.applyAllBreakpoints();
|
|
253
|
-
|
|
297
|
+
// Arm the entry-stop wait BEFORE configurationDone is sent: netcoredbg can emit
|
|
298
|
+
// `stopped` before its configurationDone response lands, and a listener attached
|
|
299
|
+
// afterwards would miss it.
|
|
300
|
+
const wantsEntryStop = mode === "launch" && args["stopAtEntry"] === true;
|
|
301
|
+
const entryStop = wantsEntryStop ? this.waitEvent("stopped", ENTRY_STOP_WAIT_MS) : null;
|
|
302
|
+
let configureFailure = null;
|
|
303
|
+
await this.request("configurationDone", {}).catch((err) => {
|
|
304
|
+
configureFailure = err;
|
|
305
|
+
});
|
|
306
|
+
// 🔴 Only treat a configurationDone failure as fatal when the adapter ADVERTISED
|
|
307
|
+
// the request. An adapter that never claimed `supportsConfigurationDoneRequest`
|
|
308
|
+
// may legitimately reject it while the session is perfectly alive — refusing
|
|
309
|
+
// there would be the over-eager mirror of the bug being fixed. netcoredbg
|
|
310
|
+
// advertises it (measured: `supportsConfigurationDoneRequest = true`), so its
|
|
311
|
+
// 0x80070002 is a real answer, not an unimplemented request.
|
|
312
|
+
const configureDoneAdvertised = this.capabilities?.["supportsConfigurationDoneRequest"] === true;
|
|
313
|
+
const fatal = this.startFailure ?? (configureDoneAdvertised ? configureFailure : null);
|
|
314
|
+
if (fatal) {
|
|
315
|
+
this.configured = false;
|
|
316
|
+
this.state = "terminated";
|
|
317
|
+
void startReq;
|
|
318
|
+
const detail = fatal?.message ?? String(fatal);
|
|
319
|
+
throw new DapError(mode, `the C# debug adapter did not start the session: ${detail}. No debug session is ` +
|
|
320
|
+
`running — check that the program exists and is a .NET assembly (${mode} args: ` +
|
|
321
|
+
`${JSON.stringify(args).slice(0, 200)}).`);
|
|
322
|
+
}
|
|
254
323
|
this.configured = true;
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
324
|
+
// 🔴 ONLY from `initialized`, never unconditionally. The adapter can emit `stopped`
|
|
325
|
+
// (or `terminated`) between the configurationDone RESPONSE and this line, and the
|
|
326
|
+
// event handler has already moved the state — a blind `= "running"` clobbers it and
|
|
327
|
+
// the awaited entry stop below then has nothing left to wait for. The first version
|
|
328
|
+
// of this fix wrote it unconditionally and passed locally for exactly that reason:
|
|
329
|
+
// the race window is small, and CI is where it opened.
|
|
330
|
+
if (this.state === "initialized")
|
|
331
|
+
this.state = "running";
|
|
332
|
+
// 🔴 `stop_on_entry` used to return before the entry stop, so the tool reported
|
|
333
|
+
// `running` and `threadId()` fell back to 1 while netcoredbg's real thread id is a
|
|
334
|
+
// large integer — cs_dbg_stack_trace answered `0x80070057` immediately after
|
|
335
|
+
// launch, and the IDENTICAL call succeeded 1.5s later. Waiting here is what makes
|
|
336
|
+
// stop-on-entry work end to end. Bounded: an adapter that ignores stopAtEntry
|
|
337
|
+
// simply reports `running`, as before.
|
|
338
|
+
if (entryStop)
|
|
339
|
+
await entryStop;
|
|
258
340
|
}
|
|
259
341
|
threadId() {
|
|
260
342
|
return this.lastStoppedThreadId ?? 1;
|