breakpoint-mcp 1.87.0 → 1.89.0

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 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. **293 tools
6
+ editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **295 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).
@@ -21,6 +21,14 @@ const SECRET_PATH := "res://.godot/breakpoint_mcp.secret"
21
21
  ## The shortest BREAKPOINT_RUNTIME_SECRET an opted-in debug export accepts (see
22
22
  ## runtime_listen_policy). The minted secret is 64 hex characters.
23
23
  const EXPORT_SECRET_MIN_LEN := 32
24
+ ## 🆕 BP-0037 — the project setting that turns strict mode on (default false, opt-in through
25
+ ## 1.x). Under it the plugin registers no autoload, the game attaches runtime_bridge.gd
26
+ ## itself from its own debug-only code, and the bridge listens only with the switch.
27
+ const STRICT_SETTING := "breakpoint_mcp/runtime/strict"
28
+ ## 🆕 BP-0037 — the developer switch: this environment variable set to 1/true, or this user
29
+ ## argument after `--` on the game's command line.
30
+ const SWITCH_ENV := "BREAKPOINT_RUNTIME"
31
+ const SWITCH_ARG := "--breakpoint-runtime"
24
32
 
25
33
 
26
34
  ## Return the shared secret, minting + persisting a fresh one if none exists.
@@ -69,14 +77,26 @@ static func load_or_mint() -> String:
69
77
  ## Pure — no OS calls, no files — so the decision is unit-tested headless; the caller
70
78
  ## passes the four facts in. `warn` is true only when someone ASKED for an exported
71
79
  ## bridge and is being refused; a plain shipped game stays silent.
72
- static func runtime_listen_policy(editor_build: bool, debug_build: bool, exported_optin: String, env_secret: String) -> Dictionary:
80
+ ##
81
+ ## 🆕 BP-0037 — TWO MORE FACTS, AND ONE TEST MOVED TO THE FRONT. `debug_build` is now read
82
+ ## FIRST, ahead of every other test: a build where OS.is_debug_build() is false never
83
+ ## listens, whatever else is true of it (the only such build that reaches here is a release
84
+ ## export, which already never listened, so nothing changes for a project that does not
85
+ ## opt in). `strict` is the project setting `breakpoint_mcp/runtime/strict` (STRICT_SETTING,
86
+ ## default false); when it is true the bridge listens only with the developer switch —
87
+ ## BREAKPOINT_RUNTIME=1 in the environment or `--breakpoint-runtime` after `--` — which
88
+ ## Breakpoint's own game launchers set (runtime_switch_on). Strict is opt-in through 1.x.
89
+ static func runtime_listen_policy(editor_build: bool, debug_build: bool, exported_optin: String, env_secret: String, strict: bool = false, switch_on: bool = false) -> Dictionary:
90
+ var optin := exported_optin.strip_edges().to_lower()
91
+ var asked := optin == "1" or optin == "true" or (strict and switch_on)
92
+ if not debug_build:
93
+ return {"listen": false, "secret_source": "", "warn": asked, "reason": "not a debug build (OS.is_debug_build() is false): the runtime bridge never listens in a release build, whatever its environment says"}
94
+ if strict and not switch_on:
95
+ return {"listen": false, "secret_source": "", "warn": optin == "1" or optin == "true", "reason": "strict mode (%s=true): the runtime bridge listens only with BREAKPOINT_RUNTIME=1 or --breakpoint-runtime" % STRICT_SETTING}
73
96
  if editor_build:
74
97
  return {"listen": true, "secret_source": "project", "warn": false, "reason": "development run (editor build)"}
75
- var optin := exported_optin.strip_edges().to_lower()
76
98
  if not (optin == "1" or optin == "true"):
77
99
  return {"listen": false, "secret_source": "", "warn": false, "reason": "exported build: the runtime bridge listens only in development runs"}
78
- if not debug_build:
79
- return {"listen": false, "secret_source": "", "warn": true, "reason": "BREAKPOINT_RUNTIME_EXPORTED is honoured only in a debug export; a release export never listens"}
80
100
  if env_secret.strip_edges().length() < EXPORT_SECRET_MIN_LEN:
81
101
  return {"listen": false, "secret_source": "", "warn": true, "reason": "BREAKPOINT_RUNTIME_EXPORTED=1 needs BREAKPOINT_RUNTIME_SECRET of at least %d characters; an exported build never mints its own" % EXPORT_SECRET_MIN_LEN}
82
102
  # The validated secret travels IN the decision, so nothing downstream re-reads the
@@ -84,6 +104,19 @@ static func runtime_listen_policy(editor_build: bool, debug_build: bool, exporte
84
104
  return {"listen": true, "secret_source": "env", "secret": env_secret.strip_edges(), "warn": false, "reason": "debug export, opted in with BREAKPOINT_RUNTIME_EXPORTED"}
85
105
 
86
106
 
107
+ ## 🆕 BP-0037 — whether the developer switch is set. Pure, like the policy: the caller passes
108
+ ## OS.get_environment(SWITCH_ENV) and OS.get_cmdline_user_args().
109
+ static func runtime_switch_on(env_value: String, user_args: PackedStringArray) -> bool:
110
+ var v := env_value.strip_edges().to_lower()
111
+ return v == "1" or v == "true" or user_args.has(SWITCH_ARG)
112
+
113
+
114
+ ## 🆕 BP-0037 — whether this project is in strict mode. Strict only on a real `true`: a
115
+ ## missing setting, or any other value, is the 1.x default (not strict).
116
+ static func runtime_strict(setting_value: Variant) -> bool:
117
+ return typeof(setting_value) == TYPE_BOOL and bool(setting_value)
118
+
119
+
87
120
  ## A development run is an EDITOR build that is NOT an export TEMPLATE. Both are compiled-in
88
121
  ## feature tags, and the second condition is what makes the first trustworthy: custom
89
122
  ## features — an export preset's list, or `_custom_features` in an override.cfg, which the
@@ -93,8 +93,9 @@ const RUNTIME_REMEDIES := {
93
93
  "emit_failed": "Check the argument count and types against the signal's declaration, then emit again.",
94
94
  "bad_action": "Call `inputmap_list` for the actions the project declares before injecting one.",
95
95
  "bad_kind": "Pass one of the kinds the message lists to `runtime_inject_input`.",
96
+ "bad_event": "Pass the fields the message names for that kind to `runtime_inject_input`; a pointer's x and y are pixels of the frame `runtime_screenshot` returns.",
96
97
  "bad_regex": "Simplify the pattern and call again — it did not compile as a Godot RegEx.",
97
- "bad_reference": "Pass a reference image the project can load — `runtime_screenshot` writes one you can diff against later.",
98
+ "bad_reference": "Pass a reference image the project can load — `runtime_screenshot_save` writes one you can diff against later.",
98
99
  "not_animation_player": "Call `runtime_get_tree` and pass the path of an AnimationPlayer node.",
99
100
  "no_animation": "Call `runtime_anim_get_state` for the animations the player actually holds.",
100
101
  "bad_scene": "Pass a res:// .tscn path that loads in the running project; `scene_get_dependencies` reports what a scene needs.",
@@ -108,6 +109,9 @@ const RUNTIME_REMEDIES := {
108
109
  "set_ignored": "Check the property exists on this node with `runtime_get_property`, and that its setter accepts the value — nothing changed.",
109
110
  "bad_event_field": "Send the field tagged — `{\"__type__\": \"Vector2\", \"x\": .., \"y\": ..}` — or omit it and let the event land at its default position.",
110
111
  "bad_params": "Fix the parameter named above and call again — the message carries the accepted shape.",
112
+ "no_state_registry": "Set Engine metadata \"breakpoint_state\" to a Dictionary of name -> Callable from the game's own debug code, then call again.",
113
+ "unknown_state": "Call `runtime_read_state` without a name for the names the game registered, then pass one of them.",
114
+ "state_reader_gone": "Set the reader again from an object that is still alive, then call again.",
111
115
  }
112
116
 
113
117
 
@@ -8,7 +8,7 @@ extends RefCounted
8
8
 
9
9
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
10
10
  const Remedies := preload("res://addons/breakpoint_mcp/error_remedies.gd")
11
- const ADDON_VERSION := "1.17.0"
11
+ const ADDON_VERSION := "1.19.0"
12
12
 
13
13
  var _plugin: EditorPlugin
14
14
 
@@ -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.17.0"
6
+ version="1.19.0"
7
7
  script="plugin.gd"
@@ -14,6 +14,7 @@ extends EditorPlugin
14
14
  const BridgeServer := preload("res://addons/breakpoint_mcp/bridge_server.gd")
15
15
  const StatusDock := preload("res://addons/breakpoint_mcp/status_dock.gd")
16
16
  const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
17
+ const BridgeSecret := preload("res://addons/breakpoint_mcp/bridge_secret.gd")
17
18
  const RUNTIME_AUTOLOAD := "BreakpointRuntimeBridge"
18
19
  const RUNTIME_SCRIPT := "res://addons/breakpoint_mcp/runtime_bridge.gd"
19
20
 
@@ -36,7 +37,15 @@ func _enter_tree() -> void:
36
37
  _dock = dock
37
38
  # Inject the runtime bridge into every run of the project so the runtime_*
38
39
  # tools work as soon as the game starts. Removed cleanly on disable.
39
- add_autoload_singleton(RUNTIME_AUTOLOAD, RUNTIME_SCRIPT)
40
+ # 🆕 BP-0037 — STRICT MODE (`breakpoint_mcp/runtime/strict=true`, opt-in through 1.x):
41
+ # the plugin neither adds nor removes the autoload. The game attaches the bridge itself,
42
+ # by path, from its own debug-only code (runtime_bridge.gd's header), so an export that
43
+ # leaves this folder out keeps no autoload entry pointing into it.
44
+ if _strict():
45
+ if ProjectSettings.has_setting("autoload/" + RUNTIME_AUTOLOAD):
46
+ push_warning("[breakpoint_mcp] strict mode is on but project.godot still registers the %s autoload; remove it (Project Settings > Globals > Autoload) and attach the bridge by path — strict mode never edits it" % RUNTIME_AUTOLOAD)
47
+ else:
48
+ add_autoload_singleton(RUNTIME_AUTOLOAD, RUNTIME_SCRIPT)
40
49
  # D3: emit resources/updated triggers. Selection + edited scene are reflected
41
50
  # in godot://editor-state; the edited tree in godot://scene-tree.
42
51
  _selection = EditorInterface.get_selection()
@@ -60,7 +69,8 @@ func _exit_tree() -> void:
60
69
  _selection = null
61
70
  if scene_changed.is_connected(_on_scene_changed):
62
71
  scene_changed.disconnect(_on_scene_changed)
63
- remove_autoload_singleton(RUNTIME_AUTOLOAD)
72
+ if not _strict():
73
+ remove_autoload_singleton(RUNTIME_AUTOLOAD)
64
74
  if _server:
65
75
  _server.shutdown()
66
76
  _server.queue_free()
@@ -70,6 +80,11 @@ func _exit_tree() -> void:
70
80
  print("[breakpoint_mcp] plugin disabled")
71
81
 
72
82
 
83
+ ## 🆕 BP-0037 — whether this project opted into strict mode (bridge_secret.gd).
84
+ func _strict() -> bool:
85
+ return BridgeSecret.runtime_strict(ProjectSettings.get_setting(BridgeSecret.STRICT_SETTING, false))
86
+
87
+
73
88
  ## D3: the editor selection changed — godot://editor-state reflects the selection.
74
89
  func _on_selection_changed() -> void:
75
90
  if _server:
@@ -15,6 +15,17 @@ extends Node
15
15
  ## An EXPORTED game carries this autoload too, and there it stays closed: no socket, no
16
16
  ## secret written, no logger hooked (325, BP-0023 — see bridge_secret.gd's
17
17
  ## runtime_listen_policy for the one opt-in, debug exports only).
18
+ ##
19
+ ## STRICT MODE (BP-0037, opt-in through 1.x): a project that sets
20
+ ## `breakpoint_mcp/runtime/strict=true` gets no autoload from the plugin. The game attaches
21
+ ## this script itself, from its own debug-only code, by path — so an export preset that
22
+ ## leaves `addons/breakpoint_mcp/` out leaves no reference behind:
23
+ ## const BP := "res://addons/breakpoint_mcp/runtime_bridge.gd"
24
+ ## if OS.is_debug_build() and ResourceLoader.exists(BP):
25
+ ## var bridge: Node = load(BP).new()
26
+ ## bridge.name = "BreakpointRuntimeBridge"
27
+ ## get_tree().root.add_child.call_deferred(bridge)
28
+ ## and the bridge then listens only with BREAKPOINT_RUNTIME=1 or `--breakpoint-runtime`.
18
29
 
19
30
  ## The addon version this GAME is running, answered on `ping`.
20
31
  ##
@@ -25,7 +36,7 @@ extends Node
25
36
  ## and the process that is already running keeps the addon it loaded. Held in lockstep
26
37
  ## with `plugin.cfg` and with `operations.gd`'s copy by contract_check check 14, which
27
38
  ## exists because two of those literals disagreed for two releases.
28
- const ADDON_VERSION := "1.17.0"
39
+ const ADDON_VERSION := "1.19.0"
29
40
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
30
41
  const Remedies := preload("res://addons/breakpoint_mcp/error_remedies.gd")
31
42
  const DEFAULT_PORT := 9081
@@ -76,6 +87,14 @@ const BridgeSecret := preload("res://addons/breakpoint_mcp/bridge_secret.gd")
76
87
  const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
77
88
  # F4: methods dispatched on the async lane (they await frames); see _handle_line / _dispatch_async.
78
89
  const ASYNC_METHODS := ["runtime.step_frames"]
90
+ # 🆕 BP-0041 (1.19.0): the input kinds runtime.inject_input answers, in the order the
91
+ # refusal lists them. `pointer` alone rides the async lane (a tap or drag spans frames).
92
+ const INPUT_KINDS := ["action", "key", "mouse_button", "mouse_motion", "joypad_button", "joypad_motion", "pointer"]
93
+ const POINTER_ACTIONS := ["press", "move", "release", "tap", "drag"]
94
+ # The pointer's `button` is a MouseButton index, as on mouse_button: 1 left, 2 right, 3 middle.
95
+ const POINTER_MASKS := {MOUSE_BUTTON_LEFT: MOUSE_BUTTON_MASK_LEFT, MOUSE_BUTTON_RIGHT: MOUSE_BUTTON_MASK_RIGHT, MOUSE_BUTTON_MIDDLE: MOUSE_BUTTON_MASK_MIDDLE}
96
+ const STRETCH_MODES := ["disabled", "canvas_items", "viewport"]
97
+ const STRETCH_ASPECTS := ["ignore", "keep", "keep_width", "keep_height", "expand"]
79
98
  # F4: default salient fields captured by runtime.state_digest when no explicit list is given.
80
99
  const _DIGEST_DEFAULT_FIELDS := ["position", "global_position", "rotation", "scale", "visible", "modulate"]
81
100
 
@@ -96,9 +115,14 @@ var _in_capture: bool = false
96
115
  ## 🔴 325 (BP-0023): the listen decision, taken first in _ready (bridge_secret.gd's
97
116
  ## runtime_listen_policy). Read by _setup_auth for where the secret comes from.
98
117
  var _policy: Dictionary = {}
118
+ ## 🆕 BP-0041: where the injected pointer last was and which buttons it holds, so a
119
+ ## `press`, `move` and `release` sent as three calls carry the relative motion and the
120
+ ## button_mask one real gesture would.
121
+ var _pointer_at := Vector2.ZERO
122
+ var _pointer_mask: int = 0
99
123
 
100
124
 
101
- ## The four facts the listen policy is decided on. A method and not inline so the
125
+ ## The facts the listen policy is decided on. A method and not inline so the
102
126
  ## headless smoke can stand an exported build up inside an editor binary — the only
103
127
  ## binary CI has — by overriding this one function and nothing else.
104
128
  func _policy_facts() -> Dictionary:
@@ -107,6 +131,9 @@ func _policy_facts() -> Dictionary:
107
131
  "debug_build": OS.is_debug_build(),
108
132
  "exported_optin": OS.get_environment("BREAKPOINT_RUNTIME_EXPORTED"),
109
133
  "env_secret": OS.get_environment("BREAKPOINT_RUNTIME_SECRET"),
134
+ # 🆕 BP-0037 — strict mode and its developer switch (bridge_secret.gd).
135
+ "strict": BridgeSecret.runtime_strict(ProjectSettings.get_setting(BridgeSecret.STRICT_SETTING, false)),
136
+ "switch": BridgeSecret.runtime_switch_on(OS.get_environment(BridgeSecret.SWITCH_ENV), OS.get_cmdline_user_args()),
110
137
  }
111
138
 
112
139
 
@@ -117,7 +144,7 @@ func _ready() -> void:
117
144
  # that calls it — and a shipped game prints nothing unless someone asked for the
118
145
  # bridge and is being refused.
119
146
  var f := _policy_facts()
120
- _policy = BridgeSecret.runtime_listen_policy(bool(f["editor_build"]), bool(f["debug_build"]), String(f["exported_optin"]), String(f["env_secret"]))
147
+ _policy = BridgeSecret.runtime_listen_policy(bool(f["editor_build"]), bool(f["debug_build"]), String(f["exported_optin"]), String(f["env_secret"]), bool(f.get("strict", false)), bool(f.get("switch", false)))
121
148
  if not bool(_policy["listen"]):
122
149
  set_process(false)
123
150
  if bool(_policy["warn"]):
@@ -294,7 +321,7 @@ func _handle_line(c: Dictionary, line: String) -> void:
294
321
  # F4: async lane — methods that await frames can't use the synchronous dispatch (which sends
295
322
  # its response immediately). Start a coroutine that sends the id'd response when done; the host
296
323
  # correlates responses by id, so an out-of-order async response is fine.
297
- if method in ASYNC_METHODS:
324
+ if method in ASYNC_METHODS or _is_pointer(method, params):
298
325
  _dispatch_async(c, id, method, params)
299
326
  return
300
327
  # D1a: the engine-error echo. The ring already receives every push_error /
@@ -471,6 +498,8 @@ func _dispatch(method: String, params: Dictionary) -> Dictionary:
471
498
  return _state_digest(params)
472
499
  "runtime.seed_rng":
473
500
  return _seed_rng(params)
501
+ "runtime.read_state":
502
+ return _read_state(params)
474
503
  "runtime.step_frames":
475
504
  # Dispatched on the async lane (see _handle_line / _dispatch_async); this placeholder
476
505
  # keeps the static contract check (which scans _dispatch case labels) aware of the
@@ -714,32 +743,242 @@ func _inject_input(params: Dictionary) -> Dictionary:
714
743
  Input.action_release(action)
715
744
  return _ok({"injected": true, "kind": kind})
716
745
  "key":
746
+ # 🆕 BP-0041: keys bound in the editor's Input Map are stored by PHYSICAL key (the
747
+ # dialog's default), and an event carrying only `keycode` never matches one — so
748
+ # today's key kind reached `_input` but not the actions. A key with none of the
749
+ # three codes is KEY_NONE, which matches nothing, so it is refused, not injected.
750
+ if not (ev.has("keycode") or ev.has("physical_keycode") or ev.has("key_label")):
751
+ return _err("bad_event", "A key event needs at least one of event.keycode, event.physical_keycode or event.key_label; with none it is KEY_NONE and matches no key and no action.")
717
752
  var k := InputEventKey.new()
718
753
  k.keycode = int(ev.get("keycode", 0))
754
+ k.physical_keycode = int(ev.get("physical_keycode", 0))
755
+ k.key_label = int(ev.get("key_label", 0))
756
+ k.unicode = int(ev.get("unicode", 0))
757
+ k.echo = bool(ev.get("echo", false))
719
758
  k.pressed = bool(ev.get("pressed", true))
759
+ _apply_modifiers(k, ev)
720
760
  Input.parse_input_event(k)
721
761
  return _ok({"injected": true, "kind": kind})
722
762
  "mouse_button":
723
763
  var mb := InputEventMouseButton.new()
724
764
  mb.button_index = int(ev.get("button", 1))
725
765
  mb.pressed = bool(ev.get("pressed", true))
726
- var pos_err := _apply_vector2(ev, "position", func(v: Vector2) -> void: mb.position = v)
766
+ mb.double_click = bool(ev.get("double_click", false))
767
+ var in_frame := String(ev.get("space", "window")) == "frame"
768
+ var pos_err := _apply_vector2(ev, "position", func(v: Vector2) -> void: mb.position = _frame_to_window(v) if in_frame else v)
727
769
  if not pos_err.is_empty():
728
770
  return pos_err
771
+ mb.global_position = mb.position
772
+ _apply_modifiers(mb, ev)
729
773
  Input.parse_input_event(mb)
730
774
  return _ok({"injected": true, "kind": kind})
731
775
  "mouse_motion":
732
776
  var mm := InputEventMouseMotion.new()
733
- var mpos_err := _apply_vector2(ev, "position", func(v: Vector2) -> void: mm.position = v)
777
+ var m_in_frame := String(ev.get("space", "window")) == "frame"
778
+ var mpos_err := _apply_vector2(ev, "position", func(v: Vector2) -> void: mm.position = _frame_to_window(v) if m_in_frame else v)
734
779
  if not mpos_err.is_empty():
735
780
  return mpos_err
736
781
  var rel_err := _apply_vector2(ev, "relative", func(v: Vector2) -> void: mm.relative = v)
737
782
  if not rel_err.is_empty():
738
783
  return rel_err
784
+ mm.global_position = mm.position
785
+ mm.button_mask = int(ev.get("button_mask", 0))
786
+ _apply_modifiers(mm, ev)
739
787
  Input.parse_input_event(mm)
740
788
  return _ok({"injected": true, "kind": kind})
789
+ "joypad_button":
790
+ if not ev.has("button"):
791
+ return _err("bad_event", "A joypad_button event needs event.button, Godot's JoyButton index (0 is the bottom face button).")
792
+ var jb := InputEventJoypadButton.new()
793
+ jb.device = int(ev.get("device", 0))
794
+ jb.button_index = int(ev.get("button"))
795
+ jb.pressed = bool(ev.get("pressed", true))
796
+ jb.pressure = float(ev.get("pressure", 1.0 if jb.pressed else 0.0))
797
+ Input.parse_input_event(jb)
798
+ return _ok({"injected": true, "kind": kind})
799
+ "joypad_motion":
800
+ if not ev.has("axis"):
801
+ return _err("bad_event", "A joypad_motion event needs event.axis, Godot's JoyAxis index (0 to 5), and event.value from -1 to 1.")
802
+ var jm := InputEventJoypadMotion.new()
803
+ jm.device = int(ev.get("device", 0))
804
+ jm.axis = int(ev.get("axis"))
805
+ jm.axis_value = clampf(float(ev.get("value", 0.0)), -1.0, 1.0)
806
+ Input.parse_input_event(jm)
807
+ return _ok({"injected": true, "kind": kind})
808
+ "pointer":
809
+ # Reached only from a caller that bypassed _handle_line's routing; the lane is async.
810
+ return _err("async_only", "runtime.inject_input with kind pointer is dispatched on the async lane")
741
811
  _:
742
- return _err("bad_kind", "Unknown input kind: %s" % kind)
812
+ return _err("bad_kind", "Unknown input kind: %s. The kinds are %s." % [kind, ", ".join(INPUT_KINDS)])
813
+
814
+
815
+ ## 🆕 BP-0041 — the four modifier flags, on any event that carries them.
816
+ func _apply_modifiers(e: InputEventWithModifiers, ev: Dictionary) -> void:
817
+ e.shift_pressed = bool(ev.get("shift", false))
818
+ e.ctrl_pressed = bool(ev.get("ctrl", false))
819
+ e.alt_pressed = bool(ev.get("alt", false))
820
+ e.meta_pressed = bool(ev.get("meta", false))
821
+
822
+
823
+ ## 🆕 BP-0041 — true when this call is a `pointer` gesture, which awaits frames and so
824
+ ## rides the async lane like runtime.step_frames.
825
+ static func _is_pointer(method: String, params: Dictionary) -> bool:
826
+ if method != "runtime.inject_input":
827
+ return false
828
+ var ev: Variant = params.get("event")
829
+ return typeof(ev) == TYPE_DICTIONARY and String((ev as Dictionary).get("kind", "")) == "pointer"
830
+
831
+
832
+ ## 🆕 BP-0041 — the geometry that relates `runtime.screenshot`'s frame to the window.
833
+ ## `texture` is the frame's own pixel size (the image's, NOT `ViewportTexture.get_size()`:
834
+ ## measured windowed on 4.7 under canvas_items, that answered 685x386 for an 888x500 frame); `visible_rect` is the viewport's coordinate
835
+ ## space (what input events and Controls use); `window` is the OS window's client size;
836
+ ## the stretch fields are the root window's content scale settings.
837
+ func _viewport_block(vp: Viewport, frame_size: Vector2) -> Dictionary:
838
+ var tex_size := frame_size
839
+ var vis := vp.get_visible_rect()
840
+ var win: Window = vp as Window if vp is Window else vp.get_window()
841
+ var block := {
842
+ "texture": [tex_size.x, tex_size.y],
843
+ "visible_rect": [vis.position.x, vis.position.y, vis.size.x, vis.size.y],
844
+ "window": [vis.size.x, vis.size.y],
845
+ "stretch_mode": "disabled",
846
+ "stretch_aspect": "ignore",
847
+ "content_scale_factor": 1.0,
848
+ }
849
+ if win != null:
850
+ block["window"] = [float(win.size.x), float(win.size.y)]
851
+ block["stretch_mode"] = STRETCH_MODES[clampi(int(win.content_scale_mode), 0, STRETCH_MODES.size() - 1)]
852
+ block["stretch_aspect"] = STRETCH_ASPECTS[clampi(int(win.content_scale_aspect), 0, STRETCH_ASPECTS.size() - 1)]
853
+ block["content_scale_factor"] = win.content_scale_factor
854
+ return block
855
+
856
+
857
+ ## 🆕 BP-0041 — the transform that carries a pixel of the frame `runtime.screenshot`
858
+ ## returns to a window pixel, in all three stretch modes.
859
+ ##
860
+ ## Godot's root window maps viewport coordinates to the window with
861
+ ## `get_final_transform()` = window_transform * stretch_transform * global_canvas_transform
862
+ ## (window.cpp, 4.3-4.7), and `Viewport._make_input_local` INVERTS exactly that for every
863
+ ## event. The frame is the viewport's render target, whose pixels are viewport coordinates
864
+ ## times the stretch scale — so frame -> window is `window_transform` alone: the letterbox
865
+ ## offset under canvas_items, offset and upscale under viewport, identity when disabled.
866
+ ## Measured windowed on the Mac (4.7, 1000x500 window, 1152x648 base, aspect keep): taps and
867
+ ## drags at frame pixels landed on their targets under all three modes.
868
+ func _frame_transform() -> Transform2D:
869
+ var vp := get_viewport()
870
+ if vp == null:
871
+ return Transform2D.IDENTITY
872
+ if vp.has_method("get_stretch_transform"): # bound from Godot 4.4
873
+ var inner: Transform2D = vp.call("get_stretch_transform") * vp.global_canvas_transform
874
+ return vp.get_final_transform() * inner.affine_inverse()
875
+ # 4.2 / 4.3 bind no stretch accessor, so the frame's own size says the stretch scale.
876
+ var tex := vp.get_texture()
877
+ var img: Image = tex.get_image() if tex != null else null
878
+ if img == null or img.get_width() <= 0 or img.get_height() <= 0:
879
+ return Transform2D.IDENTITY
880
+ var vis := vp.get_visible_rect()
881
+ var to_local := Transform2D(0.0, vis.size / Vector2(img.get_width(), img.get_height()), 0.0, vis.position)
882
+ return vp.get_final_transform() * to_local
883
+
884
+
885
+ func _frame_to_window(f: Vector2) -> Vector2:
886
+ return _frame_transform() * f
887
+
888
+
889
+ ## 🆕 BP-0041 — the pointer kind: press / move / release / tap / drag, at points given in
890
+ ## the frame's pixels (`space` "frame", the default) or the window's (`space` "window").
891
+ ## The reply carries the window points actually sent, so a miss is visible in the reply.
892
+ func _inject_pointer(ev_in: Variant) -> Dictionary:
893
+ if typeof(ev_in) != TYPE_DICTIONARY:
894
+ return _err("bad_event", "event must be an object.")
895
+ var ev: Dictionary = ev_in
896
+ var action := String(ev.get("action", ""))
897
+ if not action in POINTER_ACTIONS:
898
+ return _err("bad_event", "A pointer event needs event.action, one of %s; got %s." % [", ".join(POINTER_ACTIONS), JSON.stringify(action)])
899
+ if not (ev.has("x") and ev.has("y")):
900
+ return _err("bad_event", "A pointer event needs event.x and event.y, pixels of the frame runtime_screenshot returns.")
901
+ if action == "drag" and not (ev.has("to_x") and ev.has("to_y")):
902
+ return _err("bad_event", "A pointer drag needs event.to_x and event.to_y, where the button is released.")
903
+ var bname := int(ev.get("button", MOUSE_BUTTON_LEFT))
904
+ if not POINTER_MASKS.has(bname):
905
+ return _err("bad_event", "A pointer's event.button is a MouseButton index: 1 left, 2 right or 3 middle; got %s." % JSON.stringify(ev.get("button")))
906
+ var in_frame := String(ev.get("space", "frame")) != "window"
907
+ var warp := bool(ev.get("warp", false))
908
+ var per_step := clampi(int(ev.get("frames_per_step", 1)), 1, 10)
909
+ var xf := _frame_transform() if in_frame else Transform2D.IDENTITY
910
+ var start := xf * Vector2(float(ev.get("x")), float(ev.get("y")))
911
+ var points: Array = []
912
+ var frames := 0
913
+ match action:
914
+ "press":
915
+ _pointer_button(start, bname, true, warp)
916
+ points.append(start)
917
+ "release":
918
+ _pointer_button(start, bname, false, warp)
919
+ points.append(start)
920
+ "move":
921
+ _pointer_motion(start, warp)
922
+ points.append(start)
923
+ "tap":
924
+ _pointer_button(start, bname, true, warp)
925
+ points.append(start)
926
+ frames += await _pointer_wait(per_step)
927
+ _pointer_button(start, bname, false, warp)
928
+ points.append(start)
929
+ "drag":
930
+ var end := xf * Vector2(float(ev.get("to_x")), float(ev.get("to_y")))
931
+ var steps := clampi(int(ev.get("steps", 8)), 1, 60)
932
+ _pointer_button(start, bname, true, warp)
933
+ points.append(start)
934
+ for i in range(1, steps + 1):
935
+ frames += await _pointer_wait(per_step)
936
+ var p := start.lerp(end, float(i) / float(steps))
937
+ _pointer_motion(p, warp)
938
+ points.append(p)
939
+ frames += await _pointer_wait(per_step)
940
+ _pointer_button(end, bname, false, warp)
941
+ points.append(end)
942
+ var sent: Array = []
943
+ for p in points:
944
+ sent.append([(p as Vector2).x, (p as Vector2).y])
945
+ return _ok({"injected": true, "kind": "pointer", "window_points": sent, "frames": frames})
946
+
947
+
948
+ func _pointer_wait(n: int) -> int:
949
+ var tree := get_tree()
950
+ for _i in n:
951
+ await tree.process_frame
952
+ return n
953
+
954
+
955
+ func _pointer_button(at: Vector2, bname: int, pressed: bool, warp: bool) -> void:
956
+ if warp:
957
+ Input.warp_mouse(at)
958
+ if pressed:
959
+ _pointer_mask |= int(POINTER_MASKS[bname])
960
+ else:
961
+ _pointer_mask &= ~int(POINTER_MASKS[bname])
962
+ var mb := InputEventMouseButton.new()
963
+ mb.button_index = bname
964
+ mb.pressed = pressed
965
+ mb.position = at
966
+ mb.global_position = at
967
+ mb.button_mask = _pointer_mask
968
+ Input.parse_input_event(mb)
969
+ _pointer_at = at
970
+
971
+
972
+ func _pointer_motion(at: Vector2, warp: bool) -> void:
973
+ if warp:
974
+ Input.warp_mouse(at)
975
+ var mm := InputEventMouseMotion.new()
976
+ mm.position = at
977
+ mm.global_position = at
978
+ mm.relative = at - _pointer_at
979
+ mm.button_mask = _pointer_mask
980
+ Input.parse_input_event(mm)
981
+ _pointer_at = at
743
982
 
744
983
 
745
984
  ## Apply an OPTIONAL Vector2 field of a synthetic input event, or say why not.
@@ -825,6 +1064,7 @@ func _screenshot() -> Dictionary:
825
1064
  "base64": Marshalls.raw_to_base64(buf),
826
1065
  "width": img.get_width(),
827
1066
  "height": img.get_height(),
1067
+ "viewport": _viewport_block(vp, Vector2(img.get_width(), img.get_height())),
828
1068
  })
829
1069
 
830
1070
 
@@ -1172,6 +1412,46 @@ func _anim_get_state(params: Dictionary) -> Dictionary:
1172
1412
  return _ok(_anim_state(ap))
1173
1413
 
1174
1414
 
1415
+ # ------------------------------------------- BP-0037 D: state the game exposes ----
1416
+
1417
+ ## The Engine metadata key a game registers its state readers under. The game needs no
1418
+ ## reference to this folder or any class in it — it writes a Dictionary of
1419
+ ## name -> Callable to Engine metadata from its own debug-only code:
1420
+ ## if OS.is_debug_build():
1421
+ ## var reg: Dictionary = Engine.get_meta(&"breakpoint_state", {})
1422
+ ## reg["mixer"] = _debug_mixer_state
1423
+ ## Engine.set_meta(&"breakpoint_state", reg)
1424
+ ## so it compiles and runs unchanged in an export that leaves addons/breakpoint_mcp/ out.
1425
+ const STATE_META := &"breakpoint_state"
1426
+
1427
+
1428
+ ## runtime.read_state — read-only by construction: it reads the registry and calls only
1429
+ ## what the game chose to register there. No `name` lists the registered names.
1430
+ func _read_state(params: Dictionary) -> Dictionary:
1431
+ if not Engine.has_meta(STATE_META) or typeof(Engine.get_meta(STATE_META)) != TYPE_DICTIONARY:
1432
+ return _err("no_state_registry", "The game registered no state readers: Engine metadata \"breakpoint_state\" is not a Dictionary of name -> Callable.")
1433
+ var reg: Dictionary = Engine.get_meta(STATE_META)
1434
+ var names: Array = []
1435
+ var by_name := {}
1436
+ for k in reg.keys():
1437
+ names.append(String(k))
1438
+ by_name[String(k)] = reg[k]
1439
+ names.sort()
1440
+ var state_name := String(params.get("name", ""))
1441
+ if state_name == "":
1442
+ return _ok({"names": names})
1443
+ if not by_name.has(state_name):
1444
+ return _err("unknown_state", "No state reader named \"%s\". Registered: %s" % [state_name, ", ".join(PackedStringArray(names)) if not names.is_empty() else "(none)"])
1445
+ var reader: Variant = by_name[state_name]
1446
+ if typeof(reader) != TYPE_CALLABLE or not (reader as Callable).is_valid():
1447
+ return _err("state_reader_gone", "The reader registered as \"%s\" is no longer callable — the object behind it was freed." % state_name)
1448
+ var args: Array = []
1449
+ for a in params.get("args", []):
1450
+ args.append(Codec.decode(a))
1451
+ var value: Variant = (reader as Callable).callv(args)
1452
+ return _ok({"names": names, "name": state_name, "value": Codec.encode(value)})
1453
+
1454
+
1175
1455
  # ----------------------------------------------------- F8: node lifecycle ----
1176
1456
 
1177
1457
  func _node_add(params: Dictionary) -> Dictionary:
@@ -1258,6 +1538,8 @@ func _dispatch_async(c: Dictionary, id: Variant, method: String, params: Diction
1258
1538
  match method:
1259
1539
  "runtime.step_frames":
1260
1540
  result = await _step_frames(params)
1541
+ "runtime.inject_input":
1542
+ result = await _inject_pointer(params.get("event", {}))
1261
1543
  _:
1262
1544
  result = _err("async_only", "not an async method: %s" % method)
1263
1545
  var peer: StreamPeerTCP = c.get("peer")
@@ -14,7 +14,7 @@ const READ_ONLY = [
14
14
  "resource_load", "runtime_anim_get_state", "runtime_assert_node_state", "runtime_assert_perf",
15
15
  "runtime_assert_scene_structure", "runtime_assert_screen_text", "runtime_await_condition", "runtime_get_log",
16
16
  "runtime_get_monitors", "runtime_get_property", "runtime_get_tree", "runtime_screenshot",
17
- "runtime_peers_digest", "runtime_screenshot_diff", "runtime_state_digest", "scene_get_dependencies",
17
+ "runtime_peers_digest", "runtime_read_state", "runtime_screenshot_diff", "runtime_state_digest", "scene_get_dependencies",
18
18
  "scene_get_tree", "scene_list_open", "screenshot_editor", "selection_get", "signal_list",
19
19
  "signal_list_connections", "test_detect", "test_list", "tilemap_get_cell", "vcs_blame", "vcs_branch_list",
20
20
  "vcs_diff", "vcs_log", "vcs_show", "vcs_status",
@@ -85,8 +85,8 @@ const IDEMPOTENT = [
85
85
  "resource_set_import_settings", "resource_set_property", "rigidbody_set_properties", "runtime_anim_get_state",
86
86
  "runtime_anim_stop", "runtime_assert_node_state", "runtime_assert_perf", "runtime_assert_scene_structure",
87
87
  "runtime_assert_screen_text", "runtime_await_condition", "runtime_get_log", "runtime_get_monitors",
88
- "runtime_get_property", "runtime_get_tree", "runtime_node_remove", "runtime_peer_stop", "runtime_peers_digest",
89
- "runtime_screenshot", "runtime_screenshot_diff", "runtime_seed_rng", "runtime_set_property",
88
+ "runtime_get_property", "runtime_get_tree", "runtime_node_remove", "runtime_peer_stop", "runtime_peers_digest", "runtime_read_state",
89
+ "runtime_screenshot", "runtime_screenshot_diff", "runtime_screenshot_save", "runtime_seed_rng", "runtime_set_property",
90
90
  "runtime_state_digest", "runtime_time_scale", "scene_get_dependencies", "scene_get_tree", "scene_list_open", "scene_new", "scene_open",
91
91
  "scene_pack", "scene_reload", "scene_save", "scene_save_as", "screenshot_editor", "selection_get",
92
92
  "selection_set", "shader_create", "shader_set_code", "shadermaterial_create", "shadermaterial_set_param",
@@ -156,8 +156,8 @@ export const ALL_ANNOTATED = [
156
156
  "runtime_anim_play", "runtime_anim_stop", "runtime_assert_node_state", "runtime_assert_perf",
157
157
  "runtime_assert_scene_structure", "runtime_assert_screen_text", "runtime_await_condition", "runtime_call_method",
158
158
  "runtime_emit_signal", "runtime_get_log", "runtime_get_monitors", "runtime_get_property", "runtime_get_tree",
159
- "runtime_inject_input", "runtime_node_add", "runtime_node_remove", "runtime_peer_stop", "runtime_peers_digest",
160
- "runtime_screenshot", "runtime_screenshot_diff", "runtime_seed_rng", "runtime_set_property",
159
+ "runtime_inject_input", "runtime_node_add", "runtime_node_remove", "runtime_peer_stop", "runtime_peers_digest", "runtime_read_state",
160
+ "runtime_screenshot", "runtime_screenshot_diff", "runtime_screenshot_save", "runtime_seed_rng", "runtime_set_property",
161
161
  "runtime_spawn_peers", "runtime_state_digest", "runtime_step_frames", "runtime_time_scale", "scene_close",
162
162
  "scene_get_dependencies", "scene_get_tree",
163
163
  "scene_list_open", "scene_new", "scene_open", "scene_pack", "scene_reload", "scene_save", "scene_save_as",