@satelliteoflove/godot-mcp 3.7.0 → 3.8.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.
Files changed (57) hide show
  1. package/addon/command_router.gd +1 -0
  2. package/addon/command_router.gd.uid +1 -0
  3. package/addon/commands/animation_commands.gd.uid +1 -0
  4. package/addon/commands/debug_commands.gd +20 -1
  5. package/addon/commands/debug_commands.gd.uid +1 -0
  6. package/addon/commands/game_time_commands.gd +78 -0
  7. package/addon/commands/game_time_commands.gd.uid +1 -0
  8. package/addon/commands/node_commands.gd.uid +1 -0
  9. package/addon/commands/profiler_commands.gd.uid +1 -0
  10. package/addon/commands/project_commands.gd.uid +1 -0
  11. package/addon/commands/resource_commands.gd.uid +1 -0
  12. package/addon/commands/runtime_state_commands.gd.uid +1 -0
  13. package/addon/commands/scene3d_commands.gd.uid +1 -0
  14. package/addon/commands/scene_commands.gd.uid +1 -0
  15. package/addon/commands/screenshot_commands.gd.uid +1 -0
  16. package/addon/commands/script_commands.gd.uid +1 -0
  17. package/addon/commands/selection_commands.gd.uid +1 -0
  18. package/addon/commands/tilemap_commands.gd.uid +1 -0
  19. package/addon/core/base_command.gd.uid +1 -0
  20. package/addon/core/mcp_constants.gd.uid +1 -0
  21. package/addon/core/mcp_debugger_plugin.gd.uid +1 -0
  22. package/addon/core/mcp_enums.gd.uid +1 -0
  23. package/addon/core/mcp_logger.gd +1 -1
  24. package/addon/core/mcp_logger.gd.uid +1 -0
  25. package/addon/core/mcp_utils.gd.uid +1 -0
  26. package/addon/game_bridge/mcp_frame_profiler.gd.uid +1 -0
  27. package/addon/game_bridge/mcp_game_bridge.gd +399 -4
  28. package/addon/game_bridge/mcp_game_bridge.gd.uid +1 -0
  29. package/addon/game_bridge/mcp_runtime_state_sampler.gd.uid +1 -0
  30. package/addon/game_bridge/onscreen.gd.uid +1 -0
  31. package/addon/plugin.cfg +1 -1
  32. package/addon/plugin.gd.uid +1 -0
  33. package/addon/ui/status_panel.gd.uid +1 -0
  34. package/addon/websocket_server.gd.uid +1 -0
  35. package/dist/__tests__/tools/editor.test.js +8 -0
  36. package/dist/__tests__/tools/editor.test.js.map +1 -1
  37. package/dist/__tests__/tools/game-time.test.d.ts +2 -0
  38. package/dist/__tests__/tools/game-time.test.d.ts.map +1 -0
  39. package/dist/__tests__/tools/game-time.test.js +134 -0
  40. package/dist/__tests__/tools/game-time.test.js.map +1 -0
  41. package/dist/tools/editor.d.ts +1 -0
  42. package/dist/tools/editor.d.ts.map +1 -1
  43. package/dist/tools/editor.js +9 -2
  44. package/dist/tools/editor.js.map +1 -1
  45. package/dist/tools/game-time.d.ts +20 -0
  46. package/dist/tools/game-time.d.ts.map +1 -0
  47. package/dist/tools/game-time.js +92 -0
  48. package/dist/tools/game-time.js.map +1 -0
  49. package/dist/tools/index.d.ts +1 -0
  50. package/dist/tools/index.d.ts.map +1 -1
  51. package/dist/tools/index.js +3 -0
  52. package/dist/tools/index.js.map +1 -1
  53. package/dist/tools/input.d.ts +5 -0
  54. package/dist/tools/input.d.ts.map +1 -1
  55. package/dist/tools/input.js +1 -1
  56. package/dist/tools/input.js.map +1 -1
  57. package/package.json +1 -1
@@ -22,6 +22,7 @@ func setup(plugin: EditorPlugin) -> void:
22
22
  _register_handler(MCPInputCommands.new(), plugin)
23
23
  _register_handler(MCPProfilerCommands.new(), plugin)
24
24
  _register_handler(MCPRuntimeStateCommands.new(), plugin)
25
+ _register_handler(MCPGameTimeCommands.new(), plugin)
25
26
 
26
27
 
27
28
  func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
@@ -0,0 +1 @@
1
+ uid://bhjejek8ivdv8
@@ -0,0 +1 @@
1
+ uid://dg4xlid3mi2mi
@@ -3,6 +3,8 @@ extends MCPBaseCommand
3
3
  class_name MCPDebugCommands
4
4
 
5
5
  const DEBUG_OUTPUT_TIMEOUT := 5.0
6
+ # Keep in sync with LAUNCH_FROZEN_ENV in mcp_game_bridge.gd.
7
+ const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
6
8
 
7
9
  var _debug_output_result: PackedStringArray = []
8
10
  var _debug_output_pending: bool = false
@@ -21,15 +23,32 @@ func get_commands() -> Dictionary:
21
23
 
22
24
  func run_project(params: Dictionary) -> Dictionary:
23
25
  var scene_path: String = params.get("scene_path", "")
26
+ var frozen: bool = params.get("frozen", false)
24
27
 
25
28
  MCPLogger.clear()
26
29
 
30
+ # Launch-frozen: the spawned game inherits the editor's environment, so
31
+ # setting this before play makes the bridge freeze the tree in _ready —
32
+ # before the first process frame. Deterministic, unlike sending a freeze
33
+ # message after the debug session comes up (which races the game's first
34
+ # frames against the agent's latency).
35
+ if frozen:
36
+ OS.set_environment(LAUNCH_FROZEN_ENV, "1")
37
+
27
38
  if scene_path.is_empty():
28
39
  EditorInterface.play_main_scene()
29
40
  else:
30
41
  EditorInterface.play_custom_scene(scene_path)
31
42
 
32
- return _success({})
43
+ if frozen:
44
+ # The child captured its environment at spawn; clear promptly so a
45
+ # manual F5 run doesn't inherit the freeze. Two frames covers a
46
+ # deferred spawn. (Godot has no unset; empty fails the == "1" check.)
47
+ await Engine.get_main_loop().process_frame
48
+ await Engine.get_main_loop().process_frame
49
+ OS.set_environment(LAUNCH_FROZEN_ENV, "")
50
+
51
+ return _success({"frozen": frozen})
33
52
 
34
53
 
35
54
  func stop_project(_params: Dictionary) -> Dictionary:
@@ -0,0 +1 @@
1
+ uid://cquyem31g4y4b
@@ -0,0 +1,78 @@
1
+ @tool
2
+ extends MCPBaseCommand
3
+ class_name MCPGameTimeCommands
4
+
5
+ # Game-time control relay: freeze / step / thaw / status execute in the game
6
+ # bridge (see mcp_game_bridge.gd); this side only forwards over the debugger
7
+ # channel and waits. Timeout cascade: the step request is capped at 20s of
8
+ # game time and the bridge's wall budget returns by 25s, so the 28s relay
9
+ # timeout below fires only if the bridge is gone — and stays under the
10
+ # server's 30s command timeout so errors surface typed instead of generic.
11
+ const BASE_TIMEOUT := 10.0
12
+ const STEP_TIMEOUT := 28.0
13
+
14
+ var _last_error: Dictionary = {}
15
+
16
+
17
+ func get_commands() -> Dictionary:
18
+ return {
19
+ "game_time_freeze": game_time_freeze,
20
+ "game_time_step": game_time_step,
21
+ "game_time_thaw": game_time_thaw,
22
+ "game_time_status": game_time_status,
23
+ }
24
+
25
+
26
+ func game_time_freeze(params: Dictionary) -> Dictionary:
27
+ return await _relay("game_time_freeze", [params], BASE_TIMEOUT)
28
+
29
+
30
+ func game_time_step(params: Dictionary) -> Dictionary:
31
+ return await _relay("game_time_step", [params], STEP_TIMEOUT)
32
+
33
+
34
+ func game_time_thaw(params: Dictionary) -> Dictionary:
35
+ return await _relay("game_time_thaw", [params], BASE_TIMEOUT)
36
+
37
+
38
+ func game_time_status(params: Dictionary) -> Dictionary:
39
+ return await _relay("game_time_status", [params], BASE_TIMEOUT)
40
+
41
+
42
+ func _relay(msg_type: String, args: Array, timeout: float) -> Dictionary:
43
+ var response = await _send_and_wait(msg_type, args, timeout)
44
+ if response == null:
45
+ return _last_error
46
+ if response is Dictionary and response.has("error"):
47
+ return _error("GAME_TIME_ERROR", str(response["error"]))
48
+ if response is Dictionary:
49
+ return _success(response)
50
+ return _success({"data": response})
51
+
52
+
53
+ func _send_and_wait(msg_type: String, args: Array, timeout: float):
54
+ if not EditorInterface.is_playing_scene():
55
+ _last_error = _error("NOT_RUNNING", "No game is currently running")
56
+ return null
57
+
58
+ var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
59
+ if debugger_plugin == null or not debugger_plugin.has_active_session():
60
+ _last_error = _error("NO_SESSION", "No active debug session")
61
+ return null
62
+
63
+ var sent: bool = debugger_plugin.send_game_message(msg_type, args)
64
+ if not sent:
65
+ _last_error = _error("SEND_FAILED", "Failed to send message to game")
66
+ return null
67
+
68
+ var start_time := Time.get_ticks_msec()
69
+ while not debugger_plugin.has_response(msg_type):
70
+ await Engine.get_main_loop().process_frame
71
+ if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
72
+ debugger_plugin.clear_response(msg_type)
73
+ _last_error = _error("TIMEOUT", "Timed out waiting for %s response" % msg_type)
74
+ return null
75
+
76
+ var response = debugger_plugin.get_response(msg_type)
77
+ debugger_plugin.clear_response(msg_type)
78
+ return response
@@ -0,0 +1 @@
1
+ uid://c2pfjqe2yv0c4
@@ -0,0 +1 @@
1
+ uid://bke2rvqtw381m
@@ -0,0 +1 @@
1
+ uid://c17gnisv1uqju
@@ -0,0 +1 @@
1
+ uid://es4uegishf00
@@ -0,0 +1 @@
1
+ uid://yts2tlf0btx5
@@ -0,0 +1 @@
1
+ uid://bns7tsagruukm
@@ -0,0 +1 @@
1
+ uid://cniw11jpk5y82
@@ -0,0 +1 @@
1
+ uid://ngt4k3u88sj3
@@ -0,0 +1 @@
1
+ uid://c578gd4ovecgt
@@ -0,0 +1 @@
1
+ uid://b1om27ef4rvws
@@ -0,0 +1 @@
1
+ uid://ciy2n67gqdsla
@@ -0,0 +1 @@
1
+ uid://cietp5vxfv072
@@ -0,0 +1 @@
1
+ uid://cwqpmprsn6g2
@@ -0,0 +1 @@
1
+ uid://hkoghrp3us8j
@@ -0,0 +1 @@
1
+ uid://daogurpabpiap
@@ -0,0 +1 @@
1
+ uid://b1yx7jiol72rg
@@ -34,7 +34,7 @@ func _log_error(function: String, file: String, line: int, code: String,
34
34
  for backtrace in script_backtraces:
35
35
  for i in backtrace.get_frame_count():
36
36
  frames.append({
37
- "file": backtrace.get_frame_source(i),
37
+ "file": backtrace.get_frame_file(i),
38
38
  "line": backtrace.get_frame_line(i),
39
39
  "function": backtrace.get_frame_function(i),
40
40
  })
@@ -0,0 +1 @@
1
+ uid://yv8gf1cuf48v
@@ -0,0 +1 @@
1
+ uid://dckosaj3snxdo
@@ -0,0 +1 @@
1
+ uid://cihxpb547p4du
@@ -11,6 +11,13 @@ var _sampler: MCPRuntimeStateSampler
11
11
 
12
12
 
13
13
  func _ready() -> void:
14
+ # The bridge must keep processing while the scene tree is paused. Input
15
+ # sequences are driven from _process, so without this a press that toggles
16
+ # `paused = true` freezes the runner mid-sequence: the paired release never
17
+ # fires and the editor-side wait times out (~30s) — pause menus, a primary
18
+ # injection target, become undrivable. The bridge answers to the debugger,
19
+ # not the game's pause state. Children (the sampler) inherit this mode.
20
+ process_mode = Node.PROCESS_MODE_ALWAYS
14
21
  if not EngineDebugger.is_active():
15
22
  return
16
23
  _logger = _MCPGameLogger.new()
@@ -20,17 +27,42 @@ func _ready() -> void:
20
27
  _sampler = MCPRuntimeStateSampler.new()
21
28
  add_child(_sampler)
22
29
  EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
30
+ set_physics_process(false) # only counts ticks during a step window
23
31
  MCPLog.info("Game bridge initialized")
24
32
 
33
+ # Launch-frozen: the editor sets this env var just before spawning the game
34
+ # (godot_editor run with frozen=true), so the freeze lands before the first
35
+ # process frame — agent latency between run and the first input costs the
36
+ # game nothing. Scene _ready callbacks still run; processing does not start.
37
+ if OS.get_environment(LAUNCH_FROZEN_ENV) == "1":
38
+ _launched_frozen = true
39
+ _engage_freeze()
40
+ MCPLog.info("Game bridge: launched frozen")
41
+
25
42
 
26
43
  func _exit_tree() -> void:
44
+ # Guaranteed cleanup: never leave an action latched when the bridge node
45
+ # leaves the tree (game shutdown / scene change). Safe if nothing is held.
46
+ _release_held_actions()
27
47
  if EngineDebugger.is_active():
28
48
  EngineDebugger.unregister_message_capture("godot_mcp")
29
49
  if _profiler:
30
50
  EngineDebugger.unregister_profiler("mcp_frame_profiler")
31
51
 
32
52
 
33
- func _process(_delta: float) -> void:
53
+ func _process(delta: float) -> void:
54
+ _game_time_process(delta)
55
+ _sequence_process()
56
+
57
+
58
+ # Processing is needed by three independent features; only switch it off when
59
+ # none of them is active (the frozen monitor must run every frame, so the old
60
+ # "disable after the sequence" shortcut no longer applies unconditionally).
61
+ func _update_processing() -> void:
62
+ set_process(_sequence_running or _frozen or _step_active)
63
+
64
+
65
+ func _sequence_process() -> void:
34
66
  if not _sequence_running or _sequence_events.is_empty():
35
67
  return
36
68
 
@@ -43,12 +75,15 @@ func _process(_delta: float) -> void:
43
75
  input_event.pressed = seq_event.is_press
44
76
  input_event.strength = 1.0 if seq_event.is_press else 0.0
45
77
  Input.parse_input_event(input_event)
46
- if not seq_event.is_press:
78
+ if seq_event.is_press:
79
+ _held_actions[seq_event.action] = true
80
+ else:
81
+ _held_actions.erase(seq_event.action)
47
82
  _actions_completed += 1
48
83
 
49
84
  if _sequence_events.is_empty():
50
85
  _sequence_running = false
51
- set_process(false)
86
+ _update_processing()
52
87
  EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
53
88
  "completed": true,
54
89
  "actions_executed": _actions_completed,
@@ -60,6 +95,29 @@ var _sequence_start_time: int = 0
60
95
  var _sequence_running: bool = false
61
96
  var _actions_completed: int = 0
62
97
  var _actions_total: int = 0
98
+ # Actions whose press has been injected but whose paired release has not yet
99
+ # fired. Used to guarantee a release even if the queue is cleared mid-flight
100
+ # (new sequence) or the node leaves the tree — otherwise the dropped release
101
+ # latches the action "pressed" in the Input singleton (the stuck-held bug).
102
+ var _held_actions: Dictionary = {}
103
+
104
+
105
+ # Release any action still held from an interrupted sequence. A release here is a
106
+ # guaranteed cleanup, never a queued step that a clear could drop. Safe to call
107
+ # when nothing is held.
108
+ func _release_held_actions() -> void:
109
+ if _held_actions.is_empty():
110
+ return
111
+ for action in _held_actions.keys():
112
+ var release := InputEventAction.new()
113
+ release.action = action
114
+ release.pressed = false
115
+ release.strength = 0.0
116
+ Input.parse_input_event(release)
117
+ # Flush so the release takes effect immediately — _exit_tree may not get
118
+ # another frame, and a cleanup should be deterministic, not deferred.
119
+ Input.flush_buffered_events()
120
+ _held_actions.clear()
63
121
 
64
122
 
65
123
  func _on_debugger_message(message: String, data: Array) -> bool:
@@ -106,6 +164,18 @@ func _on_debugger_message(message: String, data: Array) -> bool:
106
164
  "watch_stop":
107
165
  _handle_watch_stop()
108
166
  return true
167
+ "game_time_freeze":
168
+ _handle_game_time_freeze(data)
169
+ return true
170
+ "game_time_step":
171
+ _handle_game_time_step(data)
172
+ return true
173
+ "game_time_thaw":
174
+ _handle_game_time_thaw(data)
175
+ return true
176
+ "game_time_status":
177
+ _handle_game_time_status(data)
178
+ return true
109
179
  return false
110
180
 
111
181
 
@@ -890,6 +960,10 @@ func _handle_execute_input_sequence(data: Array) -> void:
890
960
  }])
891
961
  return
892
962
 
963
+ # Release anything still held from a prior, interrupted sequence BEFORE
964
+ # clearing the queue — otherwise that sequence's unfired releases are dropped
965
+ # and its actions stay latched (stuck-held bug).
966
+ _release_held_actions()
893
967
  _sequence_events.clear()
894
968
  _actions_completed = 0
895
969
  _actions_total = inputs.size()
@@ -925,7 +999,7 @@ func _handle_execute_input_sequence(data: Array) -> void:
925
999
 
926
1000
  _sequence_start_time = Time.get_ticks_msec()
927
1001
  _sequence_running = true
928
- set_process(true)
1002
+ _update_processing()
929
1003
 
930
1004
 
931
1005
  func _handle_type_text(data: Array) -> void:
@@ -982,3 +1056,324 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
982
1056
  "chars_typed": text.length(),
983
1057
  "submitted": submit,
984
1058
  }])
1059
+
1060
+
1061
+ # ---------------------------------------------------------------------------
1062
+ # Game-time control (freeze / step / thaw / status)
1063
+ #
1064
+ # Real-time games race ahead of high-latency agents: 10-40s of consequences
1065
+ # land between every observation and the action it informs. These primitives
1066
+ # make game time answer to the agent's clock instead: freeze the tree, think
1067
+ # arbitrarily long (all observation tools work while frozen — rendering
1068
+ # continues during pause), then step forward a bounded slice of game time
1069
+ # with inputs riding inside the window.
1070
+ #
1071
+ # tree.paused is a single bit that two parties now write: the game's own
1072
+ # pause menu and this freeze. The bridge layers them — effective state is
1073
+ # (game_paused OR frozen) — by observing and re-asserting: it cannot
1074
+ # intercept writes, but it processes every frame (PROCESS_MODE_ALWAYS, and
1075
+ # as an autoload it runs BEFORE the scene), so a game-code flip is caught on
1076
+ # the next frame, recorded as the game layer's new intent, and overridden
1077
+ # while frozen. step/thaw restore the game's wish, not whatever we found.
1078
+ #
1079
+ # What freeze means: exactly what runs during the game's own pause menu runs
1080
+ # during freeze (WHEN_PAUSED/ALWAYS nodes, process_always timers). A game
1081
+ # with a correct pause menu has already partitioned pause-immune from
1082
+ # pausable code; freeze rides that contract. Games that "pause" by writing
1083
+ # Engine.time_scale = 0 instead are frozen solid too, but their pause is
1084
+ # invisible to the layer model (it looks like gameplay state, not pause
1085
+ # state) — documented limitation.
1086
+ # ---------------------------------------------------------------------------
1087
+
1088
+ const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
1089
+ # Timeout cascade: step request <= 20s game time, wall budget 25s, editor
1090
+ # relay 28s, server command timeout 30s. Each layer answers before the one
1091
+ # above it gives up.
1092
+ const STEP_MAX_MS := 20000
1093
+ const STEP_MAX_FRAMES := 1200
1094
+ const STEP_WALL_BUDGET_MS := 25000
1095
+ const STEP_MAX_TRANSITIONS := 50
1096
+ const FREEZE_CONTESTED_THRESHOLD := 10
1097
+
1098
+ var _frozen := false
1099
+ var _game_paused := false # the game layer's own pause intent, inferred by observation
1100
+ var _launched_frozen := false
1101
+ var _freeze_started_ticks := 0
1102
+ var _freeze_transition_count := 0
1103
+
1104
+ var _step_active := false
1105
+ var _step_finish_pending := false
1106
+ var _step_needs_settle := false
1107
+ var _step_wall_exceeded := false
1108
+ var _step_target_ms := 0.0
1109
+ var _step_target_frames := 0
1110
+ var _step_elapsed_ms := 0.0 # accumulated scaled delta = game time (wall-of-step, includes game-paused stretches)
1111
+ var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually experienced
1112
+ var _step_frames := 0
1113
+ var _step_physics_ticks := 0
1114
+ var _step_wall_start := 0
1115
+ var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
1116
+ var _step_events_fired := 0
1117
+ var _step_transitions: Array = []
1118
+ var _step_last_tree_paused := false
1119
+
1120
+
1121
+ func _send_game_time_response(msg_type: String, result: Dictionary) -> void:
1122
+ EngineDebugger.send_message("godot_mcp:game_response", [msg_type, result])
1123
+
1124
+
1125
+ func _engage_freeze() -> void:
1126
+ if _frozen:
1127
+ return
1128
+ var tree := get_tree()
1129
+ _game_paused = tree.paused
1130
+ _frozen = true
1131
+ _freeze_started_ticks = Time.get_ticks_msec()
1132
+ _freeze_transition_count = 0
1133
+ tree.paused = true
1134
+ _update_processing()
1135
+
1136
+
1137
+ # Per-frame monitor: dispatches to the step runner during a window, otherwise
1138
+ # holds the freeze against game-code writes.
1139
+ func _game_time_process(delta: float) -> void:
1140
+ if _step_active:
1141
+ _step_process(delta)
1142
+ return
1143
+ if not _frozen:
1144
+ return
1145
+ var tree := get_tree()
1146
+ if not tree.paused:
1147
+ # Game code unpaused under the freeze (a WHEN_PAUSED resume button, an
1148
+ # auto-unpausing cutscene). Record the game layer's new intent and
1149
+ # re-assert — the freeze answers to the agent; the game's wish is
1150
+ # restored on step/thaw. Only unpause flips are observable here: while
1151
+ # frozen, tree.paused is already true.
1152
+ _game_paused = false
1153
+ _freeze_transition_count += 1
1154
+ tree.paused = true
1155
+
1156
+
1157
+ func _physics_process(_delta: float) -> void:
1158
+ if _step_active and not _step_finish_pending and not get_tree().paused:
1159
+ _step_physics_ticks += 1
1160
+
1161
+
1162
+ func _handle_game_time_freeze(_data: Array) -> void:
1163
+ if _step_active:
1164
+ _send_game_time_response("game_time_freeze", {"error": "Step in progress"})
1165
+ return
1166
+ var was_frozen := _frozen
1167
+ _engage_freeze()
1168
+ _send_game_time_response("game_time_freeze", {
1169
+ "frozen": true,
1170
+ "was_frozen": was_frozen,
1171
+ "game_paused": _game_paused,
1172
+ })
1173
+
1174
+
1175
+ func _handle_game_time_thaw(_data: Array) -> void:
1176
+ if _step_active:
1177
+ _send_game_time_response("game_time_thaw", {"error": "Step in progress"})
1178
+ return
1179
+ var was_frozen := _frozen
1180
+ var result: Dictionary = {"frozen": false, "was_frozen": was_frozen}
1181
+ if was_frozen:
1182
+ # Real wall-clock the freeze was held; game time did not advance while frozen.
1183
+ result["frozen_wall_ms"] = Time.get_ticks_msec() - _freeze_started_ticks
1184
+ _frozen = false
1185
+ get_tree().paused = _game_paused
1186
+ _update_processing()
1187
+ result["game_paused"] = _game_paused if was_frozen else get_tree().paused
1188
+ _send_game_time_response("game_time_thaw", result)
1189
+
1190
+
1191
+ func _handle_game_time_status(_data: Array) -> void:
1192
+ var tree := get_tree()
1193
+ var tree_paused: bool = tree.paused if tree else false
1194
+ var result: Dictionary = {
1195
+ "frozen": _frozen,
1196
+ "game_paused": _game_paused if _frozen else tree_paused,
1197
+ "tree_paused": tree_paused,
1198
+ "engine_time_scale": Engine.time_scale,
1199
+ "physics_ticks_per_second": Engine.physics_ticks_per_second,
1200
+ }
1201
+ # `frozen` is the authoritative current state. `launched_frozen` is a historical
1202
+ # fact (this run booted frozen via GODOT_MCP_LAUNCH_FROZEN) and stays true after
1203
+ # thaw, so it must not be read as the present freeze state.
1204
+ if _launched_frozen:
1205
+ result["launched_frozen"] = true
1206
+ if _frozen:
1207
+ # Real wall-clock since freeze engaged, not game time (which is stopped).
1208
+ result["frozen_wall_ms"] = Time.get_ticks_msec() - _freeze_started_ticks
1209
+ result["freeze_transitions"] = _freeze_transition_count
1210
+ if _freeze_transition_count >= FREEZE_CONTESTED_THRESHOLD:
1211
+ # Something (an ALWAYS-mode node?) is repeatedly unpausing under
1212
+ # the freeze. Each re-assert can leak up to one frame; report the
1213
+ # contest rather than pretend the freeze is airtight.
1214
+ result["freeze_contested"] = true
1215
+ if _step_active:
1216
+ result["step_active"] = true
1217
+ _send_game_time_response("game_time_status", result)
1218
+
1219
+
1220
+ func _handle_game_time_step(data: Array) -> void:
1221
+ var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
1222
+ if _step_active:
1223
+ _send_game_time_response("game_time_step", {"error": "Step already in progress"})
1224
+ return
1225
+
1226
+ var duration_ms: int = int(params.get("duration_ms", 0))
1227
+ var frames: int = int(params.get("frames", 0))
1228
+ if duration_ms <= 0 and frames <= 0:
1229
+ _send_game_time_response("game_time_step", {"error": "step requires duration_ms or frames"})
1230
+ return
1231
+ duration_ms = mini(duration_ms, STEP_MAX_MS)
1232
+ frames = mini(frames, STEP_MAX_FRAMES)
1233
+
1234
+ # Validate and schedule the in-step input timeline (start_ms is game-time
1235
+ # from window start). Inputs must ride inside the step: an event injected
1236
+ # while frozen lands on a frame gameplay never processes, so its
1237
+ # is_action_just_pressed edge would be silently missed.
1238
+ var inputs: Array = params.get("inputs", [])
1239
+ var events: Array = []
1240
+ for input in inputs:
1241
+ var action_name: String = input.get("action_name", "")
1242
+ if action_name.is_empty():
1243
+ continue
1244
+ if not InputMap.has_action(action_name):
1245
+ _send_game_time_response("game_time_step", {"error": "Unknown action: %s" % action_name})
1246
+ return
1247
+ var start_ms: int = int(input.get("start_ms", 0))
1248
+ var dur: int = int(input.get("duration_ms", 0))
1249
+ events.append({"time": start_ms, "action": action_name, "is_press": true})
1250
+ events.append({"time": start_ms + dur, "action": action_name, "is_press": false})
1251
+ events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
1252
+ return a.time < b.time
1253
+ )
1254
+
1255
+ # Step from a running game is allowed — it freezes first, so "advance
1256
+ # 500ms then wait for me" is a single atomic call.
1257
+ _engage_freeze()
1258
+
1259
+ _step_target_ms = float(duration_ms)
1260
+ _step_target_frames = frames
1261
+ _step_elapsed_ms = 0.0
1262
+ _step_gameplay_ms = 0.0
1263
+ _step_frames = 0
1264
+ _step_physics_ticks = 0
1265
+ _step_events = events
1266
+ _step_events_fired = 0
1267
+ _step_transitions = []
1268
+ _step_needs_settle = false
1269
+ _step_finish_pending = false
1270
+ _step_wall_exceeded = false
1271
+ _step_wall_start = Time.get_ticks_msec()
1272
+ _step_active = true
1273
+
1274
+ # Open the window: restore the game layer's own pause wish for the
1275
+ # duration. If the game's menu is holding it paused, the window still
1276
+ # elapses (and reports gameplay_ms ~0) — never deadlock waiting for
1277
+ # gameplay time that cannot come.
1278
+ var tree := get_tree()
1279
+ tree.paused = _game_paused
1280
+ _step_last_tree_paused = tree.paused
1281
+ set_physics_process(true)
1282
+ _update_processing()
1283
+
1284
+
1285
+ func _step_process(delta: float) -> void:
1286
+ var tree := get_tree()
1287
+
1288
+ # The bridge processes before the scene, so a frame is counted here BEFORE
1289
+ # gameplay runs it. Ending the window therefore always defers one frame:
1290
+ # pausing in the same _process call would steal the frame just counted.
1291
+ if _step_finish_pending:
1292
+ _finish_step()
1293
+ return
1294
+
1295
+ # Game-layer pause flips during the window are the game's own doing (a
1296
+ # stepped input opened the menu, an auto-pausing cutscene). Track intent
1297
+ # and report; never fight it mid-window.
1298
+ if tree.paused != _step_last_tree_paused:
1299
+ _step_last_tree_paused = tree.paused
1300
+ _game_paused = tree.paused
1301
+ if _step_transitions.size() < STEP_MAX_TRANSITIONS:
1302
+ _step_transitions.append({"at_ms": roundi(_step_elapsed_ms), "paused": tree.paused})
1303
+
1304
+ _step_frames += 1
1305
+ _step_elapsed_ms += delta * 1000.0
1306
+ if not tree.paused:
1307
+ _step_gameplay_ms += delta * 1000.0
1308
+
1309
+ while _step_events.size() > 0 and _step_events[0].time <= _step_elapsed_ms:
1310
+ var ev: Dictionary = _step_events.pop_front()
1311
+ var input_event := InputEventAction.new()
1312
+ input_event.action = ev.action
1313
+ input_event.pressed = ev.is_press
1314
+ input_event.strength = 1.0 if ev.is_press else 0.0
1315
+ Input.parse_input_event(input_event)
1316
+ _step_events_fired += 1
1317
+ _step_needs_settle = true
1318
+ if ev.is_press:
1319
+ _held_actions[ev.action] = true
1320
+ else:
1321
+ _held_actions.erase(ev.action)
1322
+
1323
+ var done := false
1324
+ if _step_target_frames > 0:
1325
+ done = _step_frames >= _step_target_frames
1326
+ else:
1327
+ done = _step_elapsed_ms >= _step_target_ms
1328
+ if Time.get_ticks_msec() - _step_wall_start > STEP_WALL_BUDGET_MS:
1329
+ # Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
1330
+ # the game-time clock; the wall budget guarantees the call returns
1331
+ # (partial, honestly reported) before the editor relay gives up.
1332
+ _step_wall_exceeded = true
1333
+ done = true
1334
+
1335
+ if done:
1336
+ if _step_needs_settle:
1337
+ # Injected events flush at the top of the NEXT frame; gameplay
1338
+ # needs that frame unpaused or the final just_pressed edge is
1339
+ # lost. Run exactly one settle frame, then finish.
1340
+ _step_needs_settle = false
1341
+ else:
1342
+ _step_finish_pending = true
1343
+
1344
+
1345
+ func _finish_step() -> void:
1346
+ # Releases are guaranteed cleanup, never queued steps: no holds survive
1347
+ # across the freeze boundary (cross-step holds are a deliberate non-goal).
1348
+ var forced := _held_actions.size()
1349
+ _release_held_actions()
1350
+ var dropped := _step_events.size()
1351
+ _step_events.clear()
1352
+
1353
+ get_tree().paused = true # the freeze layer re-engages
1354
+ _step_last_tree_paused = true
1355
+ _step_active = false
1356
+ _step_finish_pending = false
1357
+ set_physics_process(false)
1358
+ _update_processing()
1359
+
1360
+ var result: Dictionary = {
1361
+ "completed": true,
1362
+ "frozen": true,
1363
+ "elapsed_ms": roundi(_step_elapsed_ms),
1364
+ "gameplay_ms": roundi(_step_gameplay_ms),
1365
+ "frames": _step_frames,
1366
+ "physics_ticks": _step_physics_ticks,
1367
+ "game_paused": _game_paused,
1368
+ }
1369
+ if _step_events_fired > 0:
1370
+ result["events_fired"] = _step_events_fired
1371
+ if forced > 0:
1372
+ result["forced_releases"] = forced
1373
+ if dropped > 0:
1374
+ result["events_dropped"] = dropped
1375
+ if not _step_transitions.is_empty():
1376
+ result["pause_transitions"] = _step_transitions
1377
+ if _step_wall_exceeded:
1378
+ result["wall_budget_exceeded"] = true
1379
+ _send_game_time_response("game_time_step", result)