@satelliteoflove/godot-mcp 3.5.0 → 3.6.1
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/commands/runtime_state_commands.gd +82 -0
- package/addon/commands/screenshot_commands.gd +1 -1
- package/addon/game_bridge/mcp_game_bridge.gd +299 -0
- package/addon/game_bridge/mcp_runtime_state_sampler.gd +215 -0
- package/addon/plugin.cfg +1 -1
- package/dist/__tests__/tools/runtime-state.test.d.ts +2 -0
- package/dist/__tests__/tools/runtime-state.test.d.ts.map +1 -0
- package/dist/__tests__/tools/runtime-state.test.js +302 -0
- package/dist/__tests__/tools/runtime-state.test.js.map +1 -0
- package/dist/tools/editor.js +2 -2
- package/dist/tools/editor.js.map +1 -1
- 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/runtime-state.d.ts +66 -0
- package/dist/tools/runtime-state.d.ts.map +1 -0
- package/dist/tools/runtime-state.js +204 -0
- package/dist/tools/runtime-state.js.map +1 -0
- package/package.json +3 -3
package/addon/command_router.gd
CHANGED
|
@@ -21,6 +21,7 @@ func setup(plugin: EditorPlugin) -> void:
|
|
|
21
21
|
_register_handler(MCPScene3DCommands.new(), plugin)
|
|
22
22
|
_register_handler(MCPInputCommands.new(), plugin)
|
|
23
23
|
_register_handler(MCPProfilerCommands.new(), plugin)
|
|
24
|
+
_register_handler(MCPRuntimeStateCommands.new(), plugin)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends MCPBaseCommand
|
|
3
|
+
class_name MCPRuntimeStateCommands
|
|
4
|
+
|
|
5
|
+
const GENERIC_TIMEOUT := 5.0
|
|
6
|
+
var _last_error: Dictionary = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
func get_commands() -> Dictionary:
|
|
10
|
+
return {
|
|
11
|
+
"get_runtime_state": get_runtime_state,
|
|
12
|
+
"watch_start": watch_start,
|
|
13
|
+
"watch_collect": watch_collect,
|
|
14
|
+
"watch_stop": watch_stop,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
func get_runtime_state(params: Dictionary) -> Dictionary:
|
|
19
|
+
var result = await _send_and_wait("get_runtime_state", [params])
|
|
20
|
+
if result == null:
|
|
21
|
+
return _last_error
|
|
22
|
+
if result is Dictionary:
|
|
23
|
+
return _success(result)
|
|
24
|
+
return _success({"data": result})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
func watch_start(params: Dictionary) -> Dictionary:
|
|
28
|
+
var specs: Array = params.get("specs", [])
|
|
29
|
+
var hz: int = params.get("hz", 20)
|
|
30
|
+
var duration_ms: int = params.get("duration_ms", 1000)
|
|
31
|
+
var result = await _send_and_wait("watch_start", [specs, hz, duration_ms])
|
|
32
|
+
if result == null:
|
|
33
|
+
return _last_error
|
|
34
|
+
if result is Dictionary:
|
|
35
|
+
return _success(result)
|
|
36
|
+
return _success({"started": true})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
func watch_collect(_params: Dictionary) -> Dictionary:
|
|
40
|
+
var result = await _send_and_wait("watch_collect", [])
|
|
41
|
+
if result == null:
|
|
42
|
+
return _last_error
|
|
43
|
+
if result is Dictionary:
|
|
44
|
+
return _success(result)
|
|
45
|
+
return _success({"data": result})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
func watch_stop(_params: Dictionary) -> Dictionary:
|
|
49
|
+
var result = await _send_and_wait("watch_stop", [])
|
|
50
|
+
if result == null:
|
|
51
|
+
return _last_error
|
|
52
|
+
if result is Dictionary:
|
|
53
|
+
return _success(result)
|
|
54
|
+
return _success({"stopped": true})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
func _send_and_wait(msg_type: String, args: Array = []):
|
|
58
|
+
if not EditorInterface.is_playing_scene():
|
|
59
|
+
_last_error = _error("NOT_RUNNING", "No game is currently running")
|
|
60
|
+
return null
|
|
61
|
+
|
|
62
|
+
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
|
63
|
+
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
|
64
|
+
_last_error = _error("NO_SESSION", "No active debug session")
|
|
65
|
+
return null
|
|
66
|
+
|
|
67
|
+
var sent: bool = debugger_plugin.send_game_message(msg_type, args)
|
|
68
|
+
if not sent:
|
|
69
|
+
_last_error = _error("SEND_FAILED", "Failed to send message to game")
|
|
70
|
+
return null
|
|
71
|
+
|
|
72
|
+
var start_time := Time.get_ticks_msec()
|
|
73
|
+
while not debugger_plugin.has_response(msg_type):
|
|
74
|
+
await Engine.get_main_loop().process_frame
|
|
75
|
+
if (Time.get_ticks_msec() - start_time) / 1000.0 > GENERIC_TIMEOUT:
|
|
76
|
+
debugger_plugin.clear_response(msg_type)
|
|
77
|
+
_last_error = _error("TIMEOUT", "Timed out waiting for %s response" % msg_type)
|
|
78
|
+
return null
|
|
79
|
+
|
|
80
|
+
var response = debugger_plugin.get_response(msg_type)
|
|
81
|
+
debugger_plugin.clear_response(msg_type)
|
|
82
|
+
return response
|
|
@@ -6,6 +6,7 @@ const DEFAULT_JPEG_QUALITY := 0.75
|
|
|
6
6
|
|
|
7
7
|
var _logger: _MCPGameLogger
|
|
8
8
|
var _profiler: MCPFrameProfiler
|
|
9
|
+
var _sampler: MCPRuntimeStateSampler
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
func _ready() -> void:
|
|
@@ -15,6 +16,8 @@ func _ready() -> void:
|
|
|
15
16
|
OS.add_logger(_logger)
|
|
16
17
|
_profiler = MCPFrameProfiler.new()
|
|
17
18
|
EngineDebugger.register_profiler("mcp_frame_profiler", _profiler)
|
|
19
|
+
_sampler = MCPRuntimeStateSampler.new()
|
|
20
|
+
add_child(_sampler)
|
|
18
21
|
EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
|
|
19
22
|
MCPLog.info("Game bridge initialized")
|
|
20
23
|
|
|
@@ -90,6 +93,18 @@ func _on_debugger_message(message: String, data: Array) -> bool:
|
|
|
90
93
|
"get_signal_connections":
|
|
91
94
|
_handle_get_signal_connections(data)
|
|
92
95
|
return true
|
|
96
|
+
"get_runtime_state":
|
|
97
|
+
_handle_get_runtime_state(data)
|
|
98
|
+
return true
|
|
99
|
+
"watch_start":
|
|
100
|
+
_handle_watch_start(data)
|
|
101
|
+
return true
|
|
102
|
+
"watch_collect":
|
|
103
|
+
_handle_watch_collect()
|
|
104
|
+
return true
|
|
105
|
+
"watch_stop":
|
|
106
|
+
_handle_watch_stop()
|
|
107
|
+
return true
|
|
93
108
|
return false
|
|
94
109
|
|
|
95
110
|
|
|
@@ -376,6 +391,290 @@ func _node_path_string(node: Node, scene_root: Node) -> String:
|
|
|
376
391
|
return path
|
|
377
392
|
|
|
378
393
|
|
|
394
|
+
func _handle_get_runtime_state(data: Array) -> void:
|
|
395
|
+
var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
|
|
396
|
+
|
|
397
|
+
var tree := get_tree()
|
|
398
|
+
var scene_root := tree.current_scene if tree else null
|
|
399
|
+
if not scene_root:
|
|
400
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["get_runtime_state", {
|
|
401
|
+
"scene": "",
|
|
402
|
+
"selection": "fallback",
|
|
403
|
+
"entity_count": 0,
|
|
404
|
+
"entities": [],
|
|
405
|
+
"hint": "No scene is currently running.",
|
|
406
|
+
}])
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
var select_mode: String = params.get("select", "auto")
|
|
410
|
+
var group_name: String = params.get("group", "mcp_watch")
|
|
411
|
+
var name_filter: String = params.get("name", "")
|
|
412
|
+
var type_filter: String = params.get("type", "")
|
|
413
|
+
var max_nodes: int = params.get("max_nodes", 40)
|
|
414
|
+
var include_fields: Array = params.get("include", [])
|
|
415
|
+
max_nodes = clampi(max_nodes, 1, 200)
|
|
416
|
+
|
|
417
|
+
# Resolve camera for on-screen checks
|
|
418
|
+
var camera_2d: Camera2D = _find_camera_2d()
|
|
419
|
+
var camera_viewport_rect := Rect2()
|
|
420
|
+
if camera_2d:
|
|
421
|
+
camera_viewport_rect = camera_2d.get_viewport_rect()
|
|
422
|
+
|
|
423
|
+
# Determine which selection tier to use
|
|
424
|
+
var actual_selection: String = select_mode
|
|
425
|
+
if select_mode == "auto":
|
|
426
|
+
if _has_group_members(scene_root, group_name):
|
|
427
|
+
actual_selection = "group"
|
|
428
|
+
elif _has_mcp_state_nodes(scene_root):
|
|
429
|
+
actual_selection = "method"
|
|
430
|
+
else:
|
|
431
|
+
actual_selection = "fallback"
|
|
432
|
+
|
|
433
|
+
# Collect entities
|
|
434
|
+
var entities: Array = []
|
|
435
|
+
_collect_runtime_state(scene_root, scene_root, actual_selection, group_name,
|
|
436
|
+
name_filter, type_filter, include_fields, camera_2d, camera_viewport_rect,
|
|
437
|
+
max_nodes, entities)
|
|
438
|
+
|
|
439
|
+
# Extract camera entity separately if present
|
|
440
|
+
var camera_entity = null
|
|
441
|
+
var cam_node := _find_camera_2d() if actual_selection != "fallback" else camera_2d
|
|
442
|
+
if cam_node:
|
|
443
|
+
camera_entity = {
|
|
444
|
+
"type": "Camera2D",
|
|
445
|
+
"pos": {"x": snapped(cam_node.global_position.x, 0.01), "y": snapped(cam_node.global_position.y, 0.01)},
|
|
446
|
+
"zoom": {"x": snapped(cam_node.zoom.x, 0.01), "y": snapped(cam_node.zoom.y, 0.01)},
|
|
447
|
+
"camera": true,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
var hint := ""
|
|
451
|
+
if actual_selection == "fallback":
|
|
452
|
+
hint = ("No nodes found in group '%s' and no _mcp_state() methods detected. " +
|
|
453
|
+
"For richer data: add key nodes to the '%s' group, then implement " +
|
|
454
|
+
"`func _mcp_state() -> Dictionary` on them. " +
|
|
455
|
+
"In _mcp_state(), include both live runtime values (position, health, score) " +
|
|
456
|
+
"AND static definition context (puzzle clues, level config, item data) — " +
|
|
457
|
+
"an agent needs both to understand and verify game state.") % [group_name, group_name]
|
|
458
|
+
|
|
459
|
+
var result: Dictionary = {
|
|
460
|
+
"scene": scene_root.scene_file_path,
|
|
461
|
+
"selection": actual_selection,
|
|
462
|
+
"entity_count": entities.size(),
|
|
463
|
+
"entities": entities,
|
|
464
|
+
}
|
|
465
|
+
if camera_entity:
|
|
466
|
+
result["camera"] = camera_entity
|
|
467
|
+
if not hint.is_empty():
|
|
468
|
+
result["hint"] = hint
|
|
469
|
+
|
|
470
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["get_runtime_state", result])
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
func _has_group_members(scene_root: Node, group_name: String) -> bool:
|
|
474
|
+
var tree := get_tree()
|
|
475
|
+
if tree == null:
|
|
476
|
+
return false
|
|
477
|
+
return tree.get_nodes_in_group(group_name).size() > 0
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
func _has_mcp_state_nodes(node: Node) -> bool:
|
|
481
|
+
if node.has_method("_mcp_state"):
|
|
482
|
+
return true
|
|
483
|
+
for child in node.get_children():
|
|
484
|
+
if _has_mcp_state_nodes(child):
|
|
485
|
+
return true
|
|
486
|
+
return false
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
func _collect_runtime_state(node: Node, scene_root: Node, selection: String, group_name: String,
|
|
490
|
+
name_filter: String, type_filter: String, include_fields: Array,
|
|
491
|
+
camera_2d: Camera2D, viewport_rect: Rect2,
|
|
492
|
+
max_nodes: int, results: Array) -> void:
|
|
493
|
+
if results.size() >= max_nodes:
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
var include_node := false
|
|
497
|
+
match selection:
|
|
498
|
+
"group":
|
|
499
|
+
include_node = node.is_in_group(group_name)
|
|
500
|
+
"method":
|
|
501
|
+
include_node = node.has_method("_mcp_state")
|
|
502
|
+
"fallback":
|
|
503
|
+
include_node = (node is CanvasItem and (node as CanvasItem).is_visible_in_tree())
|
|
504
|
+
|
|
505
|
+
if include_node:
|
|
506
|
+
if not name_filter.is_empty() and not node.name.matchn(name_filter):
|
|
507
|
+
include_node = false
|
|
508
|
+
if not type_filter.is_empty() and not node.is_class(type_filter):
|
|
509
|
+
include_node = false
|
|
510
|
+
|
|
511
|
+
if include_node:
|
|
512
|
+
var entity := _extract_node_state(node, scene_root, include_fields, camera_2d, viewport_rect)
|
|
513
|
+
if entity != null:
|
|
514
|
+
results.append(entity)
|
|
515
|
+
|
|
516
|
+
for child in node.get_children():
|
|
517
|
+
if results.size() >= max_nodes:
|
|
518
|
+
return
|
|
519
|
+
_collect_runtime_state(child, scene_root, selection, group_name,
|
|
520
|
+
name_filter, type_filter, include_fields, camera_2d, viewport_rect,
|
|
521
|
+
max_nodes, results)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# _mcp_state() contract: return a Dictionary with two categories —
|
|
525
|
+
# (1) live runtime values that change during play (cursor pos, health, score, fill counts)
|
|
526
|
+
# (2) static definition context needed to interpret them (puzzle clues, level layout, config)
|
|
527
|
+
# An agent can observe (1) without (2) but cannot verify correctness without both.
|
|
528
|
+
# Optionally include layout geometry (bounds, sizes) to enable programmatic layout checks.
|
|
529
|
+
# Error handling: _mcp_state() runtime errors are non-fatal in GDScript (Godot prints them
|
|
530
|
+
# and the call returns null); the `is Dictionary` check below handles that silently.
|
|
531
|
+
func _extract_node_state(node: Node, scene_root: Node, include_fields: Array,
|
|
532
|
+
camera_2d: Camera2D, viewport_rect: Rect2) -> Dictionary:
|
|
533
|
+
var want := include_fields.is_empty()
|
|
534
|
+
var want_transform := want or include_fields.has("transform")
|
|
535
|
+
var want_velocity := want or include_fields.has("velocity")
|
|
536
|
+
var want_anim := want or include_fields.has("anim")
|
|
537
|
+
var want_groups := want or include_fields.has("groups")
|
|
538
|
+
var want_onscreen := want or include_fields.has("onscreen")
|
|
539
|
+
var want_state := want or include_fields.has("state")
|
|
540
|
+
|
|
541
|
+
var entity: Dictionary = {
|
|
542
|
+
"path": _node_path_string(node, scene_root),
|
|
543
|
+
"type": node.get_class(),
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if want_groups:
|
|
547
|
+
var groups := node.get_groups().filter(func(g): return not g.begins_with("_"))
|
|
548
|
+
if not groups.is_empty():
|
|
549
|
+
entity["groups"] = groups
|
|
550
|
+
|
|
551
|
+
if want_transform and node is Node2D:
|
|
552
|
+
var n2d := node as Node2D
|
|
553
|
+
entity["pos"] = {"x": snapped(n2d.global_position.x, 0.01), "y": snapped(n2d.global_position.y, 0.01)}
|
|
554
|
+
entity["rot"] = snapped(rad_to_deg(n2d.global_rotation), 0.01)
|
|
555
|
+
if n2d.scale != Vector2.ONE:
|
|
556
|
+
entity["scale"] = {"x": snapped(n2d.scale.x, 0.01), "y": snapped(n2d.scale.y, 0.01)}
|
|
557
|
+
|
|
558
|
+
if want_transform and node is Node3D:
|
|
559
|
+
var n3d := node as Node3D
|
|
560
|
+
entity["pos"] = {
|
|
561
|
+
"x": snapped(n3d.global_position.x, 0.01),
|
|
562
|
+
"y": snapped(n3d.global_position.y, 0.01),
|
|
563
|
+
"z": snapped(n3d.global_position.z, 0.01),
|
|
564
|
+
}
|
|
565
|
+
entity["rot"] = {
|
|
566
|
+
"x": snapped(rad_to_deg(n3d.global_rotation.x), 0.01),
|
|
567
|
+
"y": snapped(rad_to_deg(n3d.global_rotation.y), 0.01),
|
|
568
|
+
"z": snapped(rad_to_deg(n3d.global_rotation.z), 0.01),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if want_velocity:
|
|
572
|
+
if node is CharacterBody2D:
|
|
573
|
+
var v := (node as CharacterBody2D).velocity
|
|
574
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01)}
|
|
575
|
+
elif node is RigidBody2D:
|
|
576
|
+
var v := (node as RigidBody2D).linear_velocity
|
|
577
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01)}
|
|
578
|
+
entity["angvel"] = snapped((node as RigidBody2D).angular_velocity, 0.01)
|
|
579
|
+
elif node is CharacterBody3D:
|
|
580
|
+
var v := (node as CharacterBody3D).velocity
|
|
581
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01), "z": snapped(v.z, 0.01)}
|
|
582
|
+
elif node is RigidBody3D:
|
|
583
|
+
var v := (node as RigidBody3D).linear_velocity
|
|
584
|
+
entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01), "z": snapped(v.z, 0.01)}
|
|
585
|
+
var av := (node as RigidBody3D).angular_velocity
|
|
586
|
+
entity["angvel"] = {"x": snapped(av.x, 0.01), "y": snapped(av.y, 0.01), "z": snapped(av.z, 0.01)}
|
|
587
|
+
|
|
588
|
+
if want_anim:
|
|
589
|
+
if node is AnimationPlayer:
|
|
590
|
+
var ap := node as AnimationPlayer
|
|
591
|
+
entity["anim"] = ap.current_animation
|
|
592
|
+
entity["anim_pos"] = snapped(ap.current_animation_position, 0.01)
|
|
593
|
+
entity["playing"] = ap.is_playing()
|
|
594
|
+
elif node is AnimatedSprite2D:
|
|
595
|
+
var asp := node as AnimatedSprite2D
|
|
596
|
+
entity["anim"] = asp.animation
|
|
597
|
+
entity["anim_frame"] = asp.frame
|
|
598
|
+
|
|
599
|
+
if want_onscreen and camera_2d and node is Node2D:
|
|
600
|
+
var pos := (node as Node2D).global_position
|
|
601
|
+
entity["onscreen"] = viewport_rect.has_point(pos)
|
|
602
|
+
|
|
603
|
+
if want_state and node.has_method("_mcp_state"):
|
|
604
|
+
var raw_state = node._mcp_state()
|
|
605
|
+
if raw_state is Dictionary:
|
|
606
|
+
var serialized := _serialize_mcp_state(raw_state)
|
|
607
|
+
if not serialized.is_empty():
|
|
608
|
+
entity["state"] = serialized
|
|
609
|
+
|
|
610
|
+
return entity
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
const _MCP_STATE_MAX_BYTES := 1024
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
func _serialize_mcp_state(state: Dictionary) -> Dictionary:
|
|
617
|
+
var result: Dictionary = {}
|
|
618
|
+
for key in state:
|
|
619
|
+
var val = state[key]
|
|
620
|
+
var serializable = null
|
|
621
|
+
match typeof(val):
|
|
622
|
+
TYPE_BOOL, TYPE_STRING:
|
|
623
|
+
serializable = val
|
|
624
|
+
TYPE_INT:
|
|
625
|
+
serializable = int(val)
|
|
626
|
+
TYPE_FLOAT:
|
|
627
|
+
serializable = snapped(float(val), 0.01)
|
|
628
|
+
TYPE_ARRAY:
|
|
629
|
+
serializable = val
|
|
630
|
+
TYPE_DICTIONARY:
|
|
631
|
+
serializable = val
|
|
632
|
+
# skip non-serializable types (Objects, NodePaths, RIDs, etc.)
|
|
633
|
+
if serializable == null:
|
|
634
|
+
continue
|
|
635
|
+
result[str(key)] = serializable
|
|
636
|
+
if JSON.stringify(result).length() > _MCP_STATE_MAX_BYTES:
|
|
637
|
+
result.erase(str(key))
|
|
638
|
+
result["_truncated"] = true
|
|
639
|
+
break
|
|
640
|
+
return result
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
func _find_camera_2d() -> Camera2D:
|
|
644
|
+
var viewport := get_viewport()
|
|
645
|
+
if viewport == null:
|
|
646
|
+
return null
|
|
647
|
+
return viewport.get_camera_2d()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
func _handle_watch_start(data: Array) -> void:
|
|
651
|
+
if _sampler == null:
|
|
652
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {"started": false, "error": "Sampler not initialized"}])
|
|
653
|
+
return
|
|
654
|
+
var specs: Array = data[0] if data.size() > 0 else []
|
|
655
|
+
var hz: int = data[1] if data.size() > 1 else 20
|
|
656
|
+
var duration_ms: int = data[2] if data.size() > 2 else 1000
|
|
657
|
+
var start_result := _sampler.start(specs, hz, duration_ms)
|
|
658
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {
|
|
659
|
+
"started": true,
|
|
660
|
+
"resolved_fields": start_result.get("resolved_fields", 0),
|
|
661
|
+
}])
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
func _handle_watch_collect() -> void:
|
|
665
|
+
if _sampler == null:
|
|
666
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", {"window_ms": 0, "sample_count": 0, "fields": {}}])
|
|
667
|
+
return
|
|
668
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", _sampler.collect()])
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
func _handle_watch_stop() -> void:
|
|
672
|
+
if _sampler == null:
|
|
673
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", {"window_ms": 0, "sample_count": 0, "fields": {}}])
|
|
674
|
+
return
|
|
675
|
+
EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", _sampler.stop()])
|
|
676
|
+
|
|
677
|
+
|
|
379
678
|
class _MCPGameLogger extends Logger:
|
|
380
679
|
var _output: PackedStringArray = []
|
|
381
680
|
var _max_lines := 1000
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
class_name MCPRuntimeStateSampler
|
|
3
|
+
|
|
4
|
+
const MAX_FIELDS := 32
|
|
5
|
+
const MAX_SAMPLES_PER_FIELD := 200
|
|
6
|
+
|
|
7
|
+
var _active: bool = false
|
|
8
|
+
var _specs: Array = [] # [{node, fields: [{key, resolver}]}]
|
|
9
|
+
var _hz: int = 20
|
|
10
|
+
var _duration_ms: int = 1000
|
|
11
|
+
var _start_time: int = 0
|
|
12
|
+
var _stop_time: int = 0 # set on auto-stop or manual stop; 0 = still running
|
|
13
|
+
var _frame_index: int = 0
|
|
14
|
+
var _sample_interval: int = 1 # sample every N frames
|
|
15
|
+
var _samples: Dictionary = {} # field_key -> Array of {t_ms, value}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
func start(specs: Array, hz: int, duration_ms: int) -> Dictionary:
|
|
19
|
+
_specs = []
|
|
20
|
+
_samples = {}
|
|
21
|
+
_hz = clampi(hz, 1, 60)
|
|
22
|
+
_duration_ms = clampi(duration_ms, 100, 5000)
|
|
23
|
+
_start_time = Time.get_ticks_msec()
|
|
24
|
+
_frame_index = 0
|
|
25
|
+
_sample_interval = max(1, int(Engine.get_frames_per_second() / _hz)) if Engine.get_frames_per_second() > 0 else max(1, int(60.0 / _hz))
|
|
26
|
+
|
|
27
|
+
var field_count := 0
|
|
28
|
+
for spec in specs:
|
|
29
|
+
if field_count >= MAX_FIELDS:
|
|
30
|
+
break
|
|
31
|
+
var node_path: String = spec.get("path", "")
|
|
32
|
+
var fields: Array = spec.get("fields", [])
|
|
33
|
+
if node_path.is_empty() or fields.is_empty():
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
var node := _resolve_node(node_path)
|
|
37
|
+
if node == null:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
var resolved_fields: Array = []
|
|
41
|
+
for field_key in fields:
|
|
42
|
+
if field_count >= MAX_FIELDS:
|
|
43
|
+
break
|
|
44
|
+
var full_key: String = node_path + ":" + str(field_key)
|
|
45
|
+
_samples[full_key] = []
|
|
46
|
+
resolved_fields.append({"key": field_key, "full_key": full_key})
|
|
47
|
+
field_count += 1
|
|
48
|
+
|
|
49
|
+
if not resolved_fields.is_empty():
|
|
50
|
+
_specs.append({"node": node, "node_path": node_path, "fields": resolved_fields})
|
|
51
|
+
|
|
52
|
+
_stop_time = 0
|
|
53
|
+
_active = true
|
|
54
|
+
set_process(true)
|
|
55
|
+
return {"resolved_fields": field_count}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
func _process(_delta: float) -> void:
|
|
59
|
+
if not _active:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
var elapsed := Time.get_ticks_msec() - _start_time
|
|
63
|
+
|
|
64
|
+
if elapsed >= _duration_ms:
|
|
65
|
+
_active = false
|
|
66
|
+
_stop_time = Time.get_ticks_msec()
|
|
67
|
+
set_process(false)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
_frame_index += 1
|
|
71
|
+
if _frame_index % _sample_interval != 0:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
for spec in _specs:
|
|
75
|
+
var node: Node = spec.node
|
|
76
|
+
if not is_instance_valid(node):
|
|
77
|
+
# node was freed — mark all fields and skip
|
|
78
|
+
for field_info in spec.fields:
|
|
79
|
+
var arr: Array = _samples.get(field_info.full_key, [])
|
|
80
|
+
if arr.size() < MAX_SAMPLES_PER_FIELD:
|
|
81
|
+
arr.append({"t_ms": elapsed, "value": "freed"})
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
for field_info in spec.fields:
|
|
85
|
+
var value = _read_field(node, field_info.key)
|
|
86
|
+
if value == null:
|
|
87
|
+
continue
|
|
88
|
+
var arr: Array = _samples.get(field_info.full_key, [])
|
|
89
|
+
if arr.size() < MAX_SAMPLES_PER_FIELD:
|
|
90
|
+
arr.append({"t_ms": elapsed, "value": value})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
func collect() -> Dictionary:
|
|
94
|
+
var elapsed: int
|
|
95
|
+
if _stop_time > 0:
|
|
96
|
+
elapsed = _stop_time - _start_time
|
|
97
|
+
elif _start_time > 0:
|
|
98
|
+
elapsed = Time.get_ticks_msec() - _start_time
|
|
99
|
+
else:
|
|
100
|
+
elapsed = 0 # never started
|
|
101
|
+
var total_samples := 0
|
|
102
|
+
for key in _samples:
|
|
103
|
+
total_samples += (_samples[key] as Array).size()
|
|
104
|
+
return {
|
|
105
|
+
"window_ms": elapsed,
|
|
106
|
+
"sample_count": total_samples,
|
|
107
|
+
"fields": _samples.duplicate(true),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
func stop() -> Dictionary:
|
|
112
|
+
_active = false
|
|
113
|
+
_stop_time = Time.get_ticks_msec()
|
|
114
|
+
set_process(false)
|
|
115
|
+
return collect()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
func is_active() -> bool:
|
|
119
|
+
return _active
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
func _resolve_node(path: String) -> Node:
|
|
123
|
+
var tree := get_tree()
|
|
124
|
+
if tree == null:
|
|
125
|
+
return null
|
|
126
|
+
var scene_root := tree.current_scene
|
|
127
|
+
if scene_root == null:
|
|
128
|
+
return null
|
|
129
|
+
|
|
130
|
+
if path == "/root/" + scene_root.name or path == "/":
|
|
131
|
+
return scene_root
|
|
132
|
+
|
|
133
|
+
if path.begins_with("/root/"):
|
|
134
|
+
var parts := path.split("/")
|
|
135
|
+
# parts[0]="", parts[1]="root", parts[2]=scene_name, parts[3+]=relative
|
|
136
|
+
if parts.size() >= 3 and parts[2] == scene_root.name:
|
|
137
|
+
if parts.size() == 3:
|
|
138
|
+
return scene_root
|
|
139
|
+
var relative := "/".join(parts.slice(3))
|
|
140
|
+
return scene_root.get_node_or_null(relative)
|
|
141
|
+
|
|
142
|
+
return scene_root.get_node_or_null(path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
func _read_field(node: Node, key: String) -> Variant:
|
|
146
|
+
match key:
|
|
147
|
+
"pos.x":
|
|
148
|
+
if node is Node2D:
|
|
149
|
+
return snapped((node as Node2D).global_position.x, 0.01)
|
|
150
|
+
if node is Node3D:
|
|
151
|
+
return snapped((node as Node3D).global_position.x, 0.01)
|
|
152
|
+
"pos.y":
|
|
153
|
+
if node is Node2D:
|
|
154
|
+
return snapped((node as Node2D).global_position.y, 0.01)
|
|
155
|
+
if node is Node3D:
|
|
156
|
+
return snapped((node as Node3D).global_position.y, 0.01)
|
|
157
|
+
"pos.z":
|
|
158
|
+
if node is Node3D:
|
|
159
|
+
return snapped((node as Node3D).global_position.z, 0.01)
|
|
160
|
+
"vel.x":
|
|
161
|
+
if node is CharacterBody2D:
|
|
162
|
+
return snapped((node as CharacterBody2D).velocity.x, 0.01)
|
|
163
|
+
if node is RigidBody2D:
|
|
164
|
+
return snapped((node as RigidBody2D).linear_velocity.x, 0.01)
|
|
165
|
+
if node is CharacterBody3D:
|
|
166
|
+
return snapped((node as CharacterBody3D).velocity.x, 0.01)
|
|
167
|
+
if node is RigidBody3D:
|
|
168
|
+
return snapped((node as RigidBody3D).linear_velocity.x, 0.01)
|
|
169
|
+
"vel.y":
|
|
170
|
+
if node is CharacterBody2D:
|
|
171
|
+
return snapped((node as CharacterBody2D).velocity.y, 0.01)
|
|
172
|
+
if node is RigidBody2D:
|
|
173
|
+
return snapped((node as RigidBody2D).linear_velocity.y, 0.01)
|
|
174
|
+
if node is CharacterBody3D:
|
|
175
|
+
return snapped((node as CharacterBody3D).velocity.y, 0.01)
|
|
176
|
+
if node is RigidBody3D:
|
|
177
|
+
return snapped((node as RigidBody3D).linear_velocity.y, 0.01)
|
|
178
|
+
"vel.z":
|
|
179
|
+
if node is CharacterBody3D:
|
|
180
|
+
return snapped((node as CharacterBody3D).velocity.z, 0.01)
|
|
181
|
+
if node is RigidBody3D:
|
|
182
|
+
return snapped((node as RigidBody3D).linear_velocity.z, 0.01)
|
|
183
|
+
"rot":
|
|
184
|
+
if node is Node2D:
|
|
185
|
+
return snapped(rad_to_deg((node as Node2D).global_rotation), 0.01)
|
|
186
|
+
if node is Node3D:
|
|
187
|
+
return snapped(rad_to_deg((node as Node3D).global_rotation.y), 0.01)
|
|
188
|
+
"anim":
|
|
189
|
+
if node is AnimationPlayer:
|
|
190
|
+
return (node as AnimationPlayer).current_animation
|
|
191
|
+
if node is AnimatedSprite2D:
|
|
192
|
+
return (node as AnimatedSprite2D).animation
|
|
193
|
+
"anim_frame":
|
|
194
|
+
if node is AnimatedSprite2D:
|
|
195
|
+
return (node as AnimatedSprite2D).frame
|
|
196
|
+
|
|
197
|
+
# Custom state fallback — `is Dictionary` guards against _mcp_state() errors,
|
|
198
|
+
# which are non-fatal in GDScript (Godot prints the error and returns null).
|
|
199
|
+
if node.has_method("_mcp_state"):
|
|
200
|
+
var state = node._mcp_state()
|
|
201
|
+
if state is Dictionary and state.has(key):
|
|
202
|
+
var val = state[key]
|
|
203
|
+
if val is float or val is int:
|
|
204
|
+
return snapped(float(val), 0.01)
|
|
205
|
+
return val
|
|
206
|
+
|
|
207
|
+
# Generic property fallback
|
|
208
|
+
if key in node:
|
|
209
|
+
var val = node.get(key)
|
|
210
|
+
if val is float or val is int:
|
|
211
|
+
return snapped(float(val), 0.01)
|
|
212
|
+
if val is String or val is bool:
|
|
213
|
+
return val
|
|
214
|
+
|
|
215
|
+
return null
|
package/addon/plugin.cfg
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime-state.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/tools/runtime-state.test.ts"],"names":[],"mappings":""}
|