@satelliteoflove/godot-mcp 3.7.1 → 3.9.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 +83 -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 +572 -3
- 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 +204 -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 +30 -0
- package/dist/tools/game-time.d.ts.map +1 -0
- package/dist/tools/game-time.js +125 -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
|
@@ -27,8 +27,18 @@ func _ready() -> void:
|
|
|
27
27
|
_sampler = MCPRuntimeStateSampler.new()
|
|
28
28
|
add_child(_sampler)
|
|
29
29
|
EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
|
|
30
|
+
set_physics_process(false) # only counts ticks during a step window
|
|
30
31
|
MCPLog.info("Game bridge initialized")
|
|
31
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
|
+
|
|
32
42
|
|
|
33
43
|
func _exit_tree() -> void:
|
|
34
44
|
# Guaranteed cleanup: never leave an action latched when the bridge node
|
|
@@ -40,7 +50,19 @@ func _exit_tree() -> void:
|
|
|
40
50
|
EngineDebugger.unregister_profiler("mcp_frame_profiler")
|
|
41
51
|
|
|
42
52
|
|
|
43
|
-
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:
|
|
44
66
|
if not _sequence_running or _sequence_events.is_empty():
|
|
45
67
|
return
|
|
46
68
|
|
|
@@ -61,7 +83,7 @@ func _process(_delta: float) -> void:
|
|
|
61
83
|
|
|
62
84
|
if _sequence_events.is_empty():
|
|
63
85
|
_sequence_running = false
|
|
64
|
-
|
|
86
|
+
_update_processing()
|
|
65
87
|
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
|
|
66
88
|
"completed": true,
|
|
67
89
|
"actions_executed": _actions_completed,
|
|
@@ -142,6 +164,21 @@ func _on_debugger_message(message: String, data: Array) -> bool:
|
|
|
142
164
|
"watch_stop":
|
|
143
165
|
_handle_watch_stop()
|
|
144
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_step_until":
|
|
174
|
+
_handle_game_time_step_until(data)
|
|
175
|
+
return true
|
|
176
|
+
"game_time_thaw":
|
|
177
|
+
_handle_game_time_thaw(data)
|
|
178
|
+
return true
|
|
179
|
+
"game_time_status":
|
|
180
|
+
_handle_game_time_status(data)
|
|
181
|
+
return true
|
|
145
182
|
return false
|
|
146
183
|
|
|
147
184
|
|
|
@@ -965,7 +1002,7 @@ func _handle_execute_input_sequence(data: Array) -> void:
|
|
|
965
1002
|
|
|
966
1003
|
_sequence_start_time = Time.get_ticks_msec()
|
|
967
1004
|
_sequence_running = true
|
|
968
|
-
|
|
1005
|
+
_update_processing()
|
|
969
1006
|
|
|
970
1007
|
|
|
971
1008
|
func _handle_type_text(data: Array) -> void:
|
|
@@ -1022,3 +1059,535 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
|
|
|
1022
1059
|
"chars_typed": text.length(),
|
|
1023
1060
|
"submitted": submit,
|
|
1024
1061
|
}])
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ---------------------------------------------------------------------------
|
|
1065
|
+
# Game-time control (freeze / step / thaw / status)
|
|
1066
|
+
#
|
|
1067
|
+
# Real-time games race ahead of high-latency agents: 10-40s of consequences
|
|
1068
|
+
# land between every observation and the action it informs. These primitives
|
|
1069
|
+
# make game time answer to the agent's clock instead: freeze the tree, think
|
|
1070
|
+
# arbitrarily long (all observation tools work while frozen — rendering
|
|
1071
|
+
# continues during pause), then step forward a bounded slice of game time
|
|
1072
|
+
# with inputs riding inside the window.
|
|
1073
|
+
#
|
|
1074
|
+
# tree.paused is a single bit that two parties now write: the game's own
|
|
1075
|
+
# pause menu and this freeze. The bridge layers them — effective state is
|
|
1076
|
+
# (game_paused OR frozen) — by observing and re-asserting: it cannot
|
|
1077
|
+
# intercept writes, but it processes every frame (PROCESS_MODE_ALWAYS, and
|
|
1078
|
+
# as an autoload it runs BEFORE the scene), so a game-code flip is caught on
|
|
1079
|
+
# the next frame, recorded as the game layer's new intent, and overridden
|
|
1080
|
+
# while frozen. step/thaw restore the game's wish, not whatever we found.
|
|
1081
|
+
#
|
|
1082
|
+
# What freeze means: exactly what runs during the game's own pause menu runs
|
|
1083
|
+
# during freeze (WHEN_PAUSED/ALWAYS nodes, process_always timers). A game
|
|
1084
|
+
# with a correct pause menu has already partitioned pause-immune from
|
|
1085
|
+
# pausable code; freeze rides that contract. Games that "pause" by writing
|
|
1086
|
+
# Engine.time_scale = 0 instead are frozen solid too, but their pause is
|
|
1087
|
+
# invisible to the layer model (it looks like gameplay state, not pause
|
|
1088
|
+
# state) — documented limitation.
|
|
1089
|
+
# ---------------------------------------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
|
|
1092
|
+
# Timeout cascade: step request <= 20s game time, wall budget 25s, editor
|
|
1093
|
+
# relay 28s, server command timeout 30s. Each layer answers before the one
|
|
1094
|
+
# above it gives up.
|
|
1095
|
+
const STEP_MAX_MS := 20000
|
|
1096
|
+
const STEP_MAX_FRAMES := 1200
|
|
1097
|
+
const STEP_WALL_BUDGET_MS := 25000
|
|
1098
|
+
const STEP_MAX_TRANSITIONS := 50
|
|
1099
|
+
const FREEZE_CONTESTED_THRESHOLD := 10
|
|
1100
|
+
|
|
1101
|
+
var _frozen := false
|
|
1102
|
+
var _game_paused := false # the game layer's own pause intent, inferred by observation
|
|
1103
|
+
var _launched_frozen := false
|
|
1104
|
+
var _freeze_started_ticks := 0
|
|
1105
|
+
var _freeze_transition_count := 0
|
|
1106
|
+
|
|
1107
|
+
var _step_active := false
|
|
1108
|
+
var _step_finish_pending := false
|
|
1109
|
+
var _step_needs_settle := false
|
|
1110
|
+
var _step_wall_exceeded := false
|
|
1111
|
+
var _step_target_ms := 0.0
|
|
1112
|
+
var _step_target_frames := 0
|
|
1113
|
+
var _step_elapsed_ms := 0.0 # accumulated scaled delta = game time (wall-of-step, includes game-paused stretches)
|
|
1114
|
+
var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually experienced
|
|
1115
|
+
var _step_frames := 0
|
|
1116
|
+
var _step_physics_ticks := 0
|
|
1117
|
+
var _step_wall_start := 0
|
|
1118
|
+
var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
|
|
1119
|
+
var _step_events_fired := 0
|
|
1120
|
+
var _step_transitions: Array = []
|
|
1121
|
+
var _step_last_tree_paused := false
|
|
1122
|
+
|
|
1123
|
+
# step_until adds a predicate evaluated each frame. _step_predicate is null for a
|
|
1124
|
+
# fixed-budget step, set for step_until; _step_response_type routes _finish_step's
|
|
1125
|
+
# reply to the matching command (the relay correlates by message type). _step_report
|
|
1126
|
+
# is the optional readings the agent wants back at stop time (in one round-trip,
|
|
1127
|
+
# instead of a separate observation call) — each is [{src: String, expr: Expression}].
|
|
1128
|
+
var _step_predicate: Expression = null
|
|
1129
|
+
var _step_predicate_inputs: Array = []
|
|
1130
|
+
var _step_predicate_met := false
|
|
1131
|
+
var _step_predicate_error := ""
|
|
1132
|
+
var _step_report: Array = []
|
|
1133
|
+
var _step_response_type := "game_time_step"
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
func _send_game_time_response(msg_type: String, result: Dictionary) -> void:
|
|
1137
|
+
EngineDebugger.send_message("godot_mcp:game_response", [msg_type, result])
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
func _engage_freeze() -> void:
|
|
1141
|
+
if _frozen:
|
|
1142
|
+
return
|
|
1143
|
+
var tree := get_tree()
|
|
1144
|
+
_game_paused = tree.paused
|
|
1145
|
+
_frozen = true
|
|
1146
|
+
_freeze_started_ticks = Time.get_ticks_msec()
|
|
1147
|
+
_freeze_transition_count = 0
|
|
1148
|
+
tree.paused = true
|
|
1149
|
+
_update_processing()
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
# Per-frame monitor: dispatches to the step runner during a window, otherwise
|
|
1153
|
+
# holds the freeze against game-code writes.
|
|
1154
|
+
func _game_time_process(delta: float) -> void:
|
|
1155
|
+
if _step_active:
|
|
1156
|
+
_step_process(delta)
|
|
1157
|
+
return
|
|
1158
|
+
if not _frozen:
|
|
1159
|
+
return
|
|
1160
|
+
var tree := get_tree()
|
|
1161
|
+
if not tree.paused:
|
|
1162
|
+
# Game code unpaused under the freeze (a WHEN_PAUSED resume button, an
|
|
1163
|
+
# auto-unpausing cutscene). Record the game layer's new intent and
|
|
1164
|
+
# re-assert — the freeze answers to the agent; the game's wish is
|
|
1165
|
+
# restored on step/thaw. Only unpause flips are observable here: while
|
|
1166
|
+
# frozen, tree.paused is already true.
|
|
1167
|
+
_game_paused = false
|
|
1168
|
+
_freeze_transition_count += 1
|
|
1169
|
+
tree.paused = true
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
func _physics_process(_delta: float) -> void:
|
|
1173
|
+
if _step_active and not _step_finish_pending and not get_tree().paused:
|
|
1174
|
+
_step_physics_ticks += 1
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
func _handle_game_time_freeze(_data: Array) -> void:
|
|
1178
|
+
if _step_active:
|
|
1179
|
+
_send_game_time_response("game_time_freeze", {"error": "Step in progress"})
|
|
1180
|
+
return
|
|
1181
|
+
var was_frozen := _frozen
|
|
1182
|
+
_engage_freeze()
|
|
1183
|
+
_send_game_time_response("game_time_freeze", {
|
|
1184
|
+
"frozen": true,
|
|
1185
|
+
"was_frozen": was_frozen,
|
|
1186
|
+
"game_paused": _game_paused,
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
func _handle_game_time_thaw(_data: Array) -> void:
|
|
1191
|
+
if _step_active:
|
|
1192
|
+
_send_game_time_response("game_time_thaw", {"error": "Step in progress"})
|
|
1193
|
+
return
|
|
1194
|
+
var was_frozen := _frozen
|
|
1195
|
+
var result: Dictionary = {"frozen": false, "was_frozen": was_frozen}
|
|
1196
|
+
if was_frozen:
|
|
1197
|
+
# Real wall-clock the freeze was held; game time did not advance while frozen.
|
|
1198
|
+
result["frozen_wall_ms"] = Time.get_ticks_msec() - _freeze_started_ticks
|
|
1199
|
+
_frozen = false
|
|
1200
|
+
get_tree().paused = _game_paused
|
|
1201
|
+
_update_processing()
|
|
1202
|
+
result["game_paused"] = _game_paused if was_frozen else get_tree().paused
|
|
1203
|
+
_send_game_time_response("game_time_thaw", result)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
func _handle_game_time_status(_data: Array) -> void:
|
|
1207
|
+
var tree := get_tree()
|
|
1208
|
+
var tree_paused: bool = tree.paused if tree else false
|
|
1209
|
+
var result: Dictionary = {
|
|
1210
|
+
"frozen": _frozen,
|
|
1211
|
+
"game_paused": _game_paused if _frozen else tree_paused,
|
|
1212
|
+
"tree_paused": tree_paused,
|
|
1213
|
+
"engine_time_scale": Engine.time_scale,
|
|
1214
|
+
"physics_ticks_per_second": Engine.physics_ticks_per_second,
|
|
1215
|
+
}
|
|
1216
|
+
# `frozen` is the authoritative current state. `launched_frozen` is a historical
|
|
1217
|
+
# fact (this run booted frozen via GODOT_MCP_LAUNCH_FROZEN) and stays true after
|
|
1218
|
+
# thaw, so it must not be read as the present freeze state.
|
|
1219
|
+
if _launched_frozen:
|
|
1220
|
+
result["launched_frozen"] = true
|
|
1221
|
+
if _frozen:
|
|
1222
|
+
# Real wall-clock since freeze engaged, not game time (which is stopped).
|
|
1223
|
+
result["frozen_wall_ms"] = Time.get_ticks_msec() - _freeze_started_ticks
|
|
1224
|
+
result["freeze_transitions"] = _freeze_transition_count
|
|
1225
|
+
if _freeze_transition_count >= FREEZE_CONTESTED_THRESHOLD:
|
|
1226
|
+
# Something (an ALWAYS-mode node?) is repeatedly unpausing under
|
|
1227
|
+
# the freeze. Each re-assert can leak up to one frame; report the
|
|
1228
|
+
# contest rather than pretend the freeze is airtight.
|
|
1229
|
+
result["freeze_contested"] = true
|
|
1230
|
+
if _step_active:
|
|
1231
|
+
result["step_active"] = true
|
|
1232
|
+
_send_game_time_response("game_time_status", result)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
func _handle_game_time_step(data: Array) -> void:
|
|
1236
|
+
var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
|
|
1237
|
+
if _step_active:
|
|
1238
|
+
_send_game_time_response("game_time_step", {"error": "Step already in progress"})
|
|
1239
|
+
return
|
|
1240
|
+
|
|
1241
|
+
var duration_ms: int = int(params.get("duration_ms", 0))
|
|
1242
|
+
var frames: int = int(params.get("frames", 0))
|
|
1243
|
+
if duration_ms <= 0 and frames <= 0:
|
|
1244
|
+
_send_game_time_response("game_time_step", {"error": "step requires duration_ms or frames"})
|
|
1245
|
+
return
|
|
1246
|
+
duration_ms = mini(duration_ms, STEP_MAX_MS)
|
|
1247
|
+
frames = mini(frames, STEP_MAX_FRAMES)
|
|
1248
|
+
|
|
1249
|
+
# Validate and schedule the in-step input timeline (start_ms is game-time
|
|
1250
|
+
# from window start). Inputs must ride inside the step: an event injected
|
|
1251
|
+
# while frozen lands on a frame gameplay never processes, so its
|
|
1252
|
+
# is_action_just_pressed edge would be silently missed.
|
|
1253
|
+
var compiled := _compile_step_events(params.get("inputs", []))
|
|
1254
|
+
if compiled.has("error"):
|
|
1255
|
+
_send_game_time_response("game_time_step", {"error": compiled["error"]})
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
# Step from a running game is allowed — it freezes first, so "advance
|
|
1259
|
+
# 500ms then wait for me" is a single atomic call.
|
|
1260
|
+
_engage_freeze()
|
|
1261
|
+
|
|
1262
|
+
_step_target_ms = float(duration_ms)
|
|
1263
|
+
_step_target_frames = frames
|
|
1264
|
+
_step_elapsed_ms = 0.0
|
|
1265
|
+
_step_gameplay_ms = 0.0
|
|
1266
|
+
_step_frames = 0
|
|
1267
|
+
_step_physics_ticks = 0
|
|
1268
|
+
_step_events = compiled["events"]
|
|
1269
|
+
_step_events_fired = 0
|
|
1270
|
+
_step_transitions = []
|
|
1271
|
+
_step_needs_settle = false
|
|
1272
|
+
_step_finish_pending = false
|
|
1273
|
+
_step_wall_exceeded = false
|
|
1274
|
+
_step_wall_start = Time.get_ticks_msec()
|
|
1275
|
+
_step_predicate = null
|
|
1276
|
+
_step_response_type = "game_time_step"
|
|
1277
|
+
_step_active = true
|
|
1278
|
+
|
|
1279
|
+
# Open the window: restore the game layer's own pause wish for the
|
|
1280
|
+
# duration. If the game's menu is holding it paused, the window still
|
|
1281
|
+
# elapses (and reports gameplay_ms ~0) — never deadlock waiting for
|
|
1282
|
+
# gameplay time that cannot come.
|
|
1283
|
+
var tree := get_tree()
|
|
1284
|
+
tree.paused = _game_paused
|
|
1285
|
+
_step_last_tree_paused = tree.paused
|
|
1286
|
+
set_physics_process(true)
|
|
1287
|
+
_update_processing()
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
func _compile_step_events(inputs: Array) -> Dictionary:
|
|
1291
|
+
# Builds the press/release timeline shared by step and step_until. start_ms
|
|
1292
|
+
# is game time from window start; returns {"error": ...} on an unknown action.
|
|
1293
|
+
var events: Array = []
|
|
1294
|
+
for input in inputs:
|
|
1295
|
+
var action_name: String = input.get("action_name", "")
|
|
1296
|
+
if action_name.is_empty():
|
|
1297
|
+
continue
|
|
1298
|
+
if not InputMap.has_action(action_name):
|
|
1299
|
+
return {"error": "Unknown action: %s" % action_name}
|
|
1300
|
+
var start_ms: int = int(input.get("start_ms", 0))
|
|
1301
|
+
var dur: int = int(input.get("duration_ms", 0))
|
|
1302
|
+
events.append({"time": start_ms, "action": action_name, "is_press": true})
|
|
1303
|
+
events.append({"time": start_ms + dur, "action": action_name, "is_press": false})
|
|
1304
|
+
events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
1305
|
+
return a.time < b.time
|
|
1306
|
+
)
|
|
1307
|
+
return {"events": events}
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
func _build_predicate_context() -> Dictionary:
|
|
1311
|
+
# Exposes the running game to a step_until predicate: every autoload by its
|
|
1312
|
+
# own name (so `G.wave > 1` just works), plus `tree` (SceneTree) and `root`
|
|
1313
|
+
# (root Window) for tree queries like
|
|
1314
|
+
# `tree.get_nodes_in_group("enemies").size() >= 1`. Chained calls must run on
|
|
1315
|
+
# these input objects, not the Expression base instance, so they are inputs.
|
|
1316
|
+
var names: Array = []
|
|
1317
|
+
var inputs: Array = []
|
|
1318
|
+
var tree := get_tree()
|
|
1319
|
+
for prop in ProjectSettings.get_property_list():
|
|
1320
|
+
var key: String = prop.get("name", "")
|
|
1321
|
+
if not key.begins_with("autoload/"):
|
|
1322
|
+
continue
|
|
1323
|
+
var autoload_name := key.substr("autoload/".length())
|
|
1324
|
+
var node := tree.root.get_node_or_null(NodePath(autoload_name))
|
|
1325
|
+
if node == null or node == self:
|
|
1326
|
+
continue # skip the bridge's own autoload and any unresolved entry
|
|
1327
|
+
names.append(autoload_name)
|
|
1328
|
+
inputs.append(node)
|
|
1329
|
+
if not names.has("tree"):
|
|
1330
|
+
names.append("tree")
|
|
1331
|
+
inputs.append(tree)
|
|
1332
|
+
if not names.has("root"):
|
|
1333
|
+
names.append("root")
|
|
1334
|
+
inputs.append(tree.root)
|
|
1335
|
+
return {"names": PackedStringArray(names), "inputs": inputs}
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
func _sanitize_value(v: Variant) -> Variant:
|
|
1339
|
+
# Report values ride back over the debugger channel. Pass primitives through;
|
|
1340
|
+
# never try to serialize Objects/containers — a short string stand-in is
|
|
1341
|
+
# enough for the agent to see what an expression evaluated to.
|
|
1342
|
+
match typeof(v):
|
|
1343
|
+
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME:
|
|
1344
|
+
return v
|
|
1345
|
+
_:
|
|
1346
|
+
return str(v).substr(0, 200)
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
func _compile_report(report: Array, names: PackedStringArray, inputs: Array) -> Dictionary:
|
|
1350
|
+
# Compile + validate each report expression in the predicate context. Returns
|
|
1351
|
+
# {"error": ...} if any fails up front, else {"report": [{src, expr}, ...]}.
|
|
1352
|
+
var compiled: Array = []
|
|
1353
|
+
for item in report:
|
|
1354
|
+
var s := str(item).strip_edges()
|
|
1355
|
+
if s.is_empty():
|
|
1356
|
+
continue
|
|
1357
|
+
var e := Expression.new()
|
|
1358
|
+
if e.parse(s, names) != OK:
|
|
1359
|
+
return {"error": "report expression parse error (%s): %s" % [s, e.get_error_text()]}
|
|
1360
|
+
e.execute(inputs, self)
|
|
1361
|
+
if e.has_execute_failed():
|
|
1362
|
+
return {"error": "report expression failed to evaluate (%s): %s" % [s, e.get_error_text()]}
|
|
1363
|
+
compiled.append({"src": s, "expr": e})
|
|
1364
|
+
return {"report": compiled}
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
func _evaluate_report(report_exprs: Array, inputs: Array) -> Dictionary:
|
|
1368
|
+
# Evaluate the compiled report expressions at stop time into {src: value}.
|
|
1369
|
+
var out: Dictionary = {}
|
|
1370
|
+
for item in report_exprs:
|
|
1371
|
+
var e: Expression = item["expr"]
|
|
1372
|
+
var v: Variant = e.execute(inputs, self)
|
|
1373
|
+
if e.has_execute_failed():
|
|
1374
|
+
out[item["src"]] = "<error: %s>" % e.get_error_text()
|
|
1375
|
+
else:
|
|
1376
|
+
out[item["src"]] = _sanitize_value(v)
|
|
1377
|
+
return out
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
func _handle_game_time_step_until(data: Array) -> void:
|
|
1381
|
+
var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
|
|
1382
|
+
if _step_active:
|
|
1383
|
+
_send_game_time_response("game_time_step_until", {"error": "Step already in progress"})
|
|
1384
|
+
return
|
|
1385
|
+
|
|
1386
|
+
var src: String = str(params.get("until", "")).strip_edges()
|
|
1387
|
+
if src.is_empty():
|
|
1388
|
+
_send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
|
|
1389
|
+
return
|
|
1390
|
+
|
|
1391
|
+
var max_ms: int = int(params.get("max_ms", STEP_MAX_MS))
|
|
1392
|
+
if max_ms <= 0:
|
|
1393
|
+
max_ms = STEP_MAX_MS
|
|
1394
|
+
max_ms = mini(max_ms, STEP_MAX_MS)
|
|
1395
|
+
|
|
1396
|
+
# Compile and validate the predicate against the live tree before committing
|
|
1397
|
+
# to a step. Expression.parse() is lenient (a malformed string can parse
|
|
1398
|
+
# clean), so a dry-run execute is what actually catches unknown identifiers
|
|
1399
|
+
# and bad member access.
|
|
1400
|
+
var ctx := _build_predicate_context()
|
|
1401
|
+
var ctx_names: PackedStringArray = ctx["names"]
|
|
1402
|
+
var ctx_inputs: Array = ctx["inputs"]
|
|
1403
|
+
var expr := Expression.new()
|
|
1404
|
+
if expr.parse(src, ctx_names) != OK:
|
|
1405
|
+
_send_game_time_response("game_time_step_until", {"error": "predicate parse error: %s" % expr.get_error_text()})
|
|
1406
|
+
return
|
|
1407
|
+
var first_value: Variant = expr.execute(ctx_inputs, self)
|
|
1408
|
+
if expr.has_execute_failed():
|
|
1409
|
+
_send_game_time_response("game_time_step_until", {"error": "predicate failed to evaluate: %s" % expr.get_error_text()})
|
|
1410
|
+
return
|
|
1411
|
+
|
|
1412
|
+
# Optional readings to return at stop time, validated up front in the same context.
|
|
1413
|
+
var report_result := _compile_report(params.get("report", []), ctx_names, ctx_inputs)
|
|
1414
|
+
if report_result.has("error"):
|
|
1415
|
+
_send_game_time_response("game_time_step_until", {"error": report_result["error"]})
|
|
1416
|
+
return
|
|
1417
|
+
var report_compiled: Array = report_result["report"]
|
|
1418
|
+
|
|
1419
|
+
var compiled := _compile_step_events(params.get("inputs", []))
|
|
1420
|
+
if compiled.has("error"):
|
|
1421
|
+
_send_game_time_response("game_time_step_until", {"error": compiled["error"]})
|
|
1422
|
+
return
|
|
1423
|
+
|
|
1424
|
+
_engage_freeze()
|
|
1425
|
+
|
|
1426
|
+
# Predicate already holds: advance nothing, stay frozen, report it.
|
|
1427
|
+
if bool(first_value):
|
|
1428
|
+
var sc_result: Dictionary = {
|
|
1429
|
+
"completed": true,
|
|
1430
|
+
"frozen": true,
|
|
1431
|
+
"elapsed_ms": 0,
|
|
1432
|
+
"gameplay_ms": 0,
|
|
1433
|
+
"frames": 0,
|
|
1434
|
+
"physics_ticks": 0,
|
|
1435
|
+
"game_paused": _game_paused,
|
|
1436
|
+
"predicate_met": true,
|
|
1437
|
+
}
|
|
1438
|
+
if not report_compiled.is_empty():
|
|
1439
|
+
sc_result["report"] = _evaluate_report(report_compiled, ctx_inputs)
|
|
1440
|
+
_send_game_time_response("game_time_step_until", sc_result)
|
|
1441
|
+
return
|
|
1442
|
+
|
|
1443
|
+
_step_target_ms = float(max_ms)
|
|
1444
|
+
_step_target_frames = 0
|
|
1445
|
+
_step_elapsed_ms = 0.0
|
|
1446
|
+
_step_gameplay_ms = 0.0
|
|
1447
|
+
_step_frames = 0
|
|
1448
|
+
_step_physics_ticks = 0
|
|
1449
|
+
_step_events = compiled["events"]
|
|
1450
|
+
_step_events_fired = 0
|
|
1451
|
+
_step_transitions = []
|
|
1452
|
+
_step_needs_settle = false
|
|
1453
|
+
_step_finish_pending = false
|
|
1454
|
+
_step_wall_exceeded = false
|
|
1455
|
+
_step_wall_start = Time.get_ticks_msec()
|
|
1456
|
+
_step_predicate = expr
|
|
1457
|
+
_step_predicate_inputs = ctx_inputs
|
|
1458
|
+
_step_predicate_met = false
|
|
1459
|
+
_step_predicate_error = ""
|
|
1460
|
+
_step_report = report_compiled
|
|
1461
|
+
_step_response_type = "game_time_step_until"
|
|
1462
|
+
_step_active = true
|
|
1463
|
+
|
|
1464
|
+
var tree := get_tree()
|
|
1465
|
+
tree.paused = _game_paused
|
|
1466
|
+
_step_last_tree_paused = tree.paused
|
|
1467
|
+
set_physics_process(true)
|
|
1468
|
+
_update_processing()
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
func _step_process(delta: float) -> void:
|
|
1472
|
+
var tree := get_tree()
|
|
1473
|
+
|
|
1474
|
+
# The bridge processes before the scene, so a frame is counted here BEFORE
|
|
1475
|
+
# gameplay runs it. Ending the window therefore always defers one frame:
|
|
1476
|
+
# pausing in the same _process call would steal the frame just counted.
|
|
1477
|
+
if _step_finish_pending:
|
|
1478
|
+
_finish_step()
|
|
1479
|
+
return
|
|
1480
|
+
|
|
1481
|
+
# Game-layer pause flips during the window are the game's own doing (a
|
|
1482
|
+
# stepped input opened the menu, an auto-pausing cutscene). Track intent
|
|
1483
|
+
# and report; never fight it mid-window.
|
|
1484
|
+
if tree.paused != _step_last_tree_paused:
|
|
1485
|
+
_step_last_tree_paused = tree.paused
|
|
1486
|
+
_game_paused = tree.paused
|
|
1487
|
+
if _step_transitions.size() < STEP_MAX_TRANSITIONS:
|
|
1488
|
+
_step_transitions.append({"at_ms": roundi(_step_elapsed_ms), "paused": tree.paused})
|
|
1489
|
+
|
|
1490
|
+
_step_frames += 1
|
|
1491
|
+
_step_elapsed_ms += delta * 1000.0
|
|
1492
|
+
if not tree.paused:
|
|
1493
|
+
_step_gameplay_ms += delta * 1000.0
|
|
1494
|
+
|
|
1495
|
+
while _step_events.size() > 0 and _step_events[0].time <= _step_elapsed_ms:
|
|
1496
|
+
var ev: Dictionary = _step_events.pop_front()
|
|
1497
|
+
var input_event := InputEventAction.new()
|
|
1498
|
+
input_event.action = ev.action
|
|
1499
|
+
input_event.pressed = ev.is_press
|
|
1500
|
+
input_event.strength = 1.0 if ev.is_press else 0.0
|
|
1501
|
+
Input.parse_input_event(input_event)
|
|
1502
|
+
_step_events_fired += 1
|
|
1503
|
+
_step_needs_settle = true
|
|
1504
|
+
if ev.is_press:
|
|
1505
|
+
_held_actions[ev.action] = true
|
|
1506
|
+
else:
|
|
1507
|
+
_held_actions.erase(ev.action)
|
|
1508
|
+
|
|
1509
|
+
var done := false
|
|
1510
|
+
if _step_target_frames > 0:
|
|
1511
|
+
done = _step_frames >= _step_target_frames
|
|
1512
|
+
else:
|
|
1513
|
+
done = _step_elapsed_ms >= _step_target_ms
|
|
1514
|
+
|
|
1515
|
+
# step_until: re-evaluate the predicate each frame against the advancing
|
|
1516
|
+
# game. A truthy result stops the window early; a runtime failure (e.g. a
|
|
1517
|
+
# watched node was freed mid-window) ends it honestly with the error attached.
|
|
1518
|
+
if _step_predicate != null:
|
|
1519
|
+
var v: Variant = _step_predicate.execute(_step_predicate_inputs, self)
|
|
1520
|
+
if _step_predicate.has_execute_failed():
|
|
1521
|
+
_step_predicate_error = _step_predicate.get_error_text()
|
|
1522
|
+
done = true
|
|
1523
|
+
elif bool(v):
|
|
1524
|
+
_step_predicate_met = true
|
|
1525
|
+
done = true
|
|
1526
|
+
|
|
1527
|
+
if Time.get_ticks_msec() - _step_wall_start > STEP_WALL_BUDGET_MS:
|
|
1528
|
+
# Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
|
|
1529
|
+
# the game-time clock; the wall budget guarantees the call returns
|
|
1530
|
+
# (partial, honestly reported) before the editor relay gives up.
|
|
1531
|
+
_step_wall_exceeded = true
|
|
1532
|
+
done = true
|
|
1533
|
+
|
|
1534
|
+
if done:
|
|
1535
|
+
if _step_needs_settle:
|
|
1536
|
+
# Injected events flush at the top of the NEXT frame; gameplay
|
|
1537
|
+
# needs that frame unpaused or the final just_pressed edge is
|
|
1538
|
+
# lost. Run exactly one settle frame, then finish.
|
|
1539
|
+
_step_needs_settle = false
|
|
1540
|
+
else:
|
|
1541
|
+
_step_finish_pending = true
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
func _finish_step() -> void:
|
|
1545
|
+
# Releases are guaranteed cleanup, never queued steps: no holds survive
|
|
1546
|
+
# across the freeze boundary (cross-step holds are a deliberate non-goal).
|
|
1547
|
+
var forced := _held_actions.size()
|
|
1548
|
+
_release_held_actions()
|
|
1549
|
+
var dropped := _step_events.size()
|
|
1550
|
+
_step_events.clear()
|
|
1551
|
+
|
|
1552
|
+
get_tree().paused = true # the freeze layer re-engages
|
|
1553
|
+
_step_last_tree_paused = true
|
|
1554
|
+
_step_active = false
|
|
1555
|
+
_step_finish_pending = false
|
|
1556
|
+
set_physics_process(false)
|
|
1557
|
+
_update_processing()
|
|
1558
|
+
|
|
1559
|
+
var result: Dictionary = {
|
|
1560
|
+
"completed": true,
|
|
1561
|
+
"frozen": true,
|
|
1562
|
+
"elapsed_ms": roundi(_step_elapsed_ms),
|
|
1563
|
+
"gameplay_ms": roundi(_step_gameplay_ms),
|
|
1564
|
+
"frames": _step_frames,
|
|
1565
|
+
"physics_ticks": _step_physics_ticks,
|
|
1566
|
+
"game_paused": _game_paused,
|
|
1567
|
+
}
|
|
1568
|
+
if _step_events_fired > 0:
|
|
1569
|
+
result["events_fired"] = _step_events_fired
|
|
1570
|
+
if forced > 0:
|
|
1571
|
+
result["forced_releases"] = forced
|
|
1572
|
+
if dropped > 0:
|
|
1573
|
+
result["events_dropped"] = dropped
|
|
1574
|
+
if not _step_transitions.is_empty():
|
|
1575
|
+
result["pause_transitions"] = _step_transitions
|
|
1576
|
+
if _step_wall_exceeded:
|
|
1577
|
+
result["wall_budget_exceeded"] = true
|
|
1578
|
+
if _step_predicate != null:
|
|
1579
|
+
# step_until: predicate_met is the headline. report carries the readings the
|
|
1580
|
+
# agent asked for (the "what advanced" hint, so it need not re-observe). A
|
|
1581
|
+
# non-met return means the cap or wall budget ran out first.
|
|
1582
|
+
result["predicate_met"] = _step_predicate_met
|
|
1583
|
+
if not _step_report.is_empty():
|
|
1584
|
+
result["report"] = _evaluate_report(_step_report, _step_predicate_inputs)
|
|
1585
|
+
if not _step_predicate_error.is_empty():
|
|
1586
|
+
result["predicate_error"] = _step_predicate_error
|
|
1587
|
+
|
|
1588
|
+
# Route the reply to the originating command — the relay correlates by type.
|
|
1589
|
+
var response_type := _step_response_type
|
|
1590
|
+
_step_predicate = null
|
|
1591
|
+
_step_predicate_inputs = []
|
|
1592
|
+
_step_report = []
|
|
1593
|
+
_send_game_time_response(response_type, result)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://dj1awl7wmpf65
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://1fhae7bg8bgf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bn5y5te1ys8sa
|
package/addon/plugin.cfg
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://ddy5vjisb0we
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://ccs8jj6mvw2i7
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://dcdsm34jvcwgv
|
|
@@ -53,6 +53,14 @@ describe('editor tool', () => {
|
|
|
53
53
|
.toBe('Running scene: res://test.tscn');
|
|
54
54
|
expect(await editor.execute({ action: 'stop' }, ctx)).toBe('Stopped project');
|
|
55
55
|
});
|
|
56
|
+
it('passes frozen through to run_project and says so', async () => {
|
|
57
|
+
mock.mockResponse({});
|
|
58
|
+
const ctx = createToolContext(mock);
|
|
59
|
+
const result = await editor.execute({ action: 'run', frozen: true }, ctx);
|
|
60
|
+
expect(result).toContain('frozen from frame 0');
|
|
61
|
+
expect(mock.calls[0].command).toBe('run_project');
|
|
62
|
+
expect(mock.calls[0].params.frozen).toBe(true);
|
|
63
|
+
});
|
|
56
64
|
});
|
|
57
65
|
describe('get_log_messages', () => {
|
|
58
66
|
it('returns "No log messages" when empty', async () => {
|