@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.
- package/addon/command_router.gd +1 -0
- package/addon/command_router.gd.uid +1 -0
- package/addon/commands/animation_commands.gd.uid +1 -0
- package/addon/commands/debug_commands.gd +20 -1
- package/addon/commands/debug_commands.gd.uid +1 -0
- package/addon/commands/game_time_commands.gd +78 -0
- package/addon/commands/game_time_commands.gd.uid +1 -0
- package/addon/commands/node_commands.gd.uid +1 -0
- package/addon/commands/profiler_commands.gd.uid +1 -0
- package/addon/commands/project_commands.gd.uid +1 -0
- package/addon/commands/resource_commands.gd.uid +1 -0
- package/addon/commands/runtime_state_commands.gd.uid +1 -0
- package/addon/commands/scene3d_commands.gd.uid +1 -0
- package/addon/commands/scene_commands.gd.uid +1 -0
- package/addon/commands/screenshot_commands.gd.uid +1 -0
- package/addon/commands/script_commands.gd.uid +1 -0
- package/addon/commands/selection_commands.gd.uid +1 -0
- package/addon/commands/tilemap_commands.gd.uid +1 -0
- package/addon/core/base_command.gd.uid +1 -0
- package/addon/core/mcp_constants.gd.uid +1 -0
- package/addon/core/mcp_debugger_plugin.gd.uid +1 -0
- package/addon/core/mcp_enums.gd.uid +1 -0
- package/addon/core/mcp_logger.gd +1 -1
- package/addon/core/mcp_logger.gd.uid +1 -0
- package/addon/core/mcp_utils.gd.uid +1 -0
- package/addon/game_bridge/mcp_frame_profiler.gd.uid +1 -0
- package/addon/game_bridge/mcp_game_bridge.gd +399 -4
- package/addon/game_bridge/mcp_game_bridge.gd.uid +1 -0
- package/addon/game_bridge/mcp_runtime_state_sampler.gd.uid +1 -0
- package/addon/game_bridge/onscreen.gd.uid +1 -0
- package/addon/plugin.cfg +1 -1
- package/addon/plugin.gd.uid +1 -0
- package/addon/ui/status_panel.gd.uid +1 -0
- package/addon/websocket_server.gd.uid +1 -0
- package/dist/__tests__/tools/editor.test.js +8 -0
- package/dist/__tests__/tools/editor.test.js.map +1 -1
- package/dist/__tests__/tools/game-time.test.d.ts +2 -0
- package/dist/__tests__/tools/game-time.test.d.ts.map +1 -0
- package/dist/__tests__/tools/game-time.test.js +134 -0
- package/dist/__tests__/tools/game-time.test.js.map +1 -0
- package/dist/tools/editor.d.ts +1 -0
- package/dist/tools/editor.d.ts.map +1 -1
- package/dist/tools/editor.js +9 -2
- package/dist/tools/editor.js.map +1 -1
- package/dist/tools/game-time.d.ts +20 -0
- package/dist/tools/game-time.d.ts.map +1 -0
- package/dist/tools/game-time.js +92 -0
- package/dist/tools/game-time.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/input.d.ts +5 -0
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +1 -1
- package/dist/tools/input.js.map +1 -1
- package/package.json +1 -1
package/addon/command_router.gd
CHANGED
|
@@ -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
|
-
|
|
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
|
package/addon/core/mcp_logger.gd
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|