@satelliteoflove/godot-mcp 3.15.0 → 3.17.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.
@@ -137,10 +137,12 @@ func _event_to_string(event: InputEvent) -> String:
137
137
  return "Mouse Button %d" % mouse_event.button_index
138
138
  elif event is InputEventJoypadButton:
139
139
  var joy_event := event as InputEventJoypadButton
140
- return "Joypad Button %d" % joy_event.button_index
140
+ return "Joypad Button %d (%s)" % [joy_event.button_index, MCPJoyNames.button_name(joy_event.button_index)]
141
141
  elif event is InputEventJoypadMotion:
142
+ # The signed axis_value is the direction bit an agent needs to lift the
143
+ # binding straight into an injection (e.g. move_left = left_x, value -1.0).
142
144
  var joy_motion := event as InputEventJoypadMotion
143
- return "Joypad Axis %d" % joy_motion.axis
145
+ return "Joypad Axis %d (%s, value %+.1f)" % [joy_motion.axis, MCPJoyNames.axis_name(joy_motion.axis), joy_motion.axis_value]
144
146
  return event.as_text()
145
147
 
146
148
 
@@ -28,7 +28,8 @@ func watch_start(params: Dictionary) -> Dictionary:
28
28
  var specs: Array = params.get("specs", [])
29
29
  var hz: int = params.get("hz", 20)
30
30
  var duration_ms: int = params.get("duration_ms", 1000)
31
- var result = await _send_and_wait("watch_start", [specs, hz, duration_ms])
31
+ var sigs: Array = params.get("signals", [])
32
+ var result = await _send_and_wait("watch_start", [specs, hz, duration_ms, sigs])
32
33
  if result == null:
33
34
  return _last_error
34
35
  if result is Dictionary:
@@ -0,0 +1,68 @@
1
+ @tool
2
+ class_name MCPJoyNames
3
+ extends RefCounted
4
+ ## Canonical joypad name<->index tables, shared by the game bridge (event
5
+ ## injection) and the editor input commands (get_input_map display) so the
6
+ ## wire vocabulary and the displayed names can never drift apart.
7
+
8
+ const BUTTONS := {
9
+ "a": JOY_BUTTON_A,
10
+ "b": JOY_BUTTON_B,
11
+ "x": JOY_BUTTON_X,
12
+ "y": JOY_BUTTON_Y,
13
+ "back": JOY_BUTTON_BACK,
14
+ "guide": JOY_BUTTON_GUIDE,
15
+ "start": JOY_BUTTON_START,
16
+ "left_stick": JOY_BUTTON_LEFT_STICK,
17
+ "right_stick": JOY_BUTTON_RIGHT_STICK,
18
+ "left_shoulder": JOY_BUTTON_LEFT_SHOULDER,
19
+ "right_shoulder": JOY_BUTTON_RIGHT_SHOULDER,
20
+ "dpad_up": JOY_BUTTON_DPAD_UP,
21
+ "dpad_down": JOY_BUTTON_DPAD_DOWN,
22
+ "dpad_left": JOY_BUTTON_DPAD_LEFT,
23
+ "dpad_right": JOY_BUTTON_DPAD_RIGHT,
24
+ "misc1": JOY_BUTTON_MISC1,
25
+ "paddle1": JOY_BUTTON_PADDLE1,
26
+ "paddle2": JOY_BUTTON_PADDLE2,
27
+ "paddle3": JOY_BUTTON_PADDLE3,
28
+ "paddle4": JOY_BUTTON_PADDLE4,
29
+ "touchpad": JOY_BUTTON_TOUCHPAD,
30
+ }
31
+
32
+ const AXES := {
33
+ "left_x": JOY_AXIS_LEFT_X,
34
+ "left_y": JOY_AXIS_LEFT_Y,
35
+ "right_x": JOY_AXIS_RIGHT_X,
36
+ "right_y": JOY_AXIS_RIGHT_Y,
37
+ "trigger_left": JOY_AXIS_TRIGGER_LEFT,
38
+ "trigger_right": JOY_AXIS_TRIGGER_RIGHT,
39
+ }
40
+
41
+
42
+ ## Resolve a wire value (name string, or raw index as an escape hatch) to a
43
+ ## JoyButton index. Returns -1 for anything unknown.
44
+ static func button_index(value: Variant) -> int:
45
+ if value is int or value is float:
46
+ var i := int(value)
47
+ return i if i >= 0 and i < JOY_BUTTON_SDL_MAX else -1
48
+ return int(BUTTONS.get(str(value), -1))
49
+
50
+
51
+ ## Resolve a wire axis name to a JoyAxis index. Names only — the name itself
52
+ ## is what tells an agent trigger (0..1) from stick (-1..1). Returns -1 if unknown.
53
+ static func axis_index(value: Variant) -> int:
54
+ return int(AXES.get(str(value), -1))
55
+
56
+
57
+ static func button_name(idx: int) -> String:
58
+ for n in BUTTONS:
59
+ if int(BUTTONS[n]) == idx:
60
+ return n
61
+ return "button_%d" % idx
62
+
63
+
64
+ static func axis_name(idx: int) -> String:
65
+ for n in AXES:
66
+ if int(AXES[n]) == idx:
67
+ return n
68
+ return "axis_%d" % idx
@@ -0,0 +1 @@
1
+ uid://row4hmo5gdem
@@ -137,16 +137,7 @@ func _sequence_process(delta: float) -> void:
137
137
 
138
138
  while _sequence_events.size() > 0 and _sequence_events[0].time <= elapsed:
139
139
  var seq_event: Dictionary = _sequence_events.pop_front()
140
- var input_event := InputEventAction.new()
141
- input_event.action = seq_event.action
142
- input_event.pressed = seq_event.is_press
143
- input_event.strength = 1.0 if seq_event.is_press else 0.0
144
- Input.parse_input_event(input_event)
145
- if seq_event.is_press:
146
- _held_actions[seq_event.action] = true
147
- else:
148
- _held_actions.erase(seq_event.action)
149
- _actions_completed += 1
140
+ _actions_completed += _inject_timeline_event(seq_event)
150
141
 
151
142
  # Trigger any frame captures whose offset has arrived (#239). Capture is
152
143
  # deferred to frame_post_draw, so it completes a frame or two later; the
@@ -191,6 +182,7 @@ func _emit_sequence_result() -> void:
191
182
  "frozen": _frozen,
192
183
  "gameplay_ms": roundi(_sequence_gameplay_ms),
193
184
  "wall_ms": Time.get_ticks_msec() - _sequence_start_time,
185
+ "input_kinds": _sequence_input_kinds,
194
186
  }
195
187
 
196
188
  if not _sequence_report.is_empty():
@@ -262,6 +254,11 @@ var _sequence_start_time: int = 0
262
254
  var _sequence_running: bool = false
263
255
  var _actions_completed: int = 0
264
256
  var _actions_total: int = 0
257
+ # Entry counts by kind ({action, joy_button, axis}) for the current sequence /
258
+ # step window. Echoed in results: its PRESENCE is the version-skew signal a new
259
+ # server uses to detect an old bridge that silently dropped joypad entries.
260
+ var _sequence_input_kinds: Dictionary = {}
261
+ var _step_input_kinds: Dictionary = {}
265
262
  # Game time (unpaused, scaled) accumulated across the sequence window — compared
266
263
  # against wall time in the result to flag a sequence that ran under a pause/freeze.
267
264
  var _sequence_gameplay_ms: float = 0.0
@@ -295,13 +292,19 @@ const SEQUENCE_MAX_CAPTURE_OFFSET_MS := 300000
295
292
  # (new sequence) or the node leaves the tree — otherwise the dropped release
296
293
  # latches the action "pressed" in the Input singleton (the stuck-held bug).
297
294
  var _held_actions: Dictionary = {}
295
+ # Same guarantee for joypad state (#233): buttons whose press has fired
296
+ # (key "device:button") and axes whose last-set value is nonzero
297
+ # (key "device:axis") — a dropped end event would otherwise latch the polled
298
+ # Input singletons (is_joy_button_pressed / get_joy_axis) until game restart.
299
+ var _held_joy_buttons: Dictionary = {}
300
+ var _active_axes: Dictionary = {}
298
301
 
299
302
 
300
- # Release any action still held from an interrupted sequence. A release here is a
301
- # guaranteed cleanup, never a queued step that a clear could drop. Safe to call
302
- # when nothing is held.
303
+ # Release any action/button still held and re-zero any active axis from an
304
+ # interrupted sequence. A release here is a guaranteed cleanup, never a queued
305
+ # step that a clear could drop. Safe to call when nothing is held.
303
306
  func _release_held_actions() -> void:
304
- if _held_actions.is_empty():
307
+ if _held_actions.is_empty() and _held_joy_buttons.is_empty() and _active_axes.is_empty():
305
308
  return
306
309
  for action in _held_actions.keys():
307
310
  var release := InputEventAction.new()
@@ -309,10 +312,26 @@ func _release_held_actions() -> void:
309
312
  release.pressed = false
310
313
  release.strength = 0.0
311
314
  Input.parse_input_event(release)
315
+ for bkey in _held_joy_buttons.keys():
316
+ var binfo = _held_joy_buttons[bkey]
317
+ var brel := InputEventJoypadButton.new()
318
+ brel.device = int(binfo["device"])
319
+ brel.button_index = int(binfo["button"]) as JoyButton
320
+ brel.pressed = false
321
+ Input.parse_input_event(brel)
322
+ for akey in _active_axes.keys():
323
+ var ainfo = _active_axes[akey]
324
+ var azero := InputEventJoypadMotion.new()
325
+ azero.device = int(ainfo["device"])
326
+ azero.axis = int(ainfo["axis"]) as JoyAxis
327
+ azero.axis_value = 0.0
328
+ Input.parse_input_event(azero)
312
329
  # Flush so the release takes effect immediately — _exit_tree may not get
313
330
  # another frame, and a cleanup should be deterministic, not deferred.
314
331
  Input.flush_buffered_events()
315
332
  _held_actions.clear()
333
+ _held_joy_buttons.clear()
334
+ _active_axes.clear()
316
335
 
317
336
 
318
337
  func _on_debugger_message(message: String, data: Array) -> bool:
@@ -1063,23 +1082,26 @@ func _handle_watch_start(data: Array) -> void:
1063
1082
  var specs: Array = data[0] if data.size() > 0 else []
1064
1083
  var hz: int = data[1] if data.size() > 1 else 20
1065
1084
  var duration_ms: int = data[2] if data.size() > 2 else 1000
1066
- var start_result := _sampler.start(specs, hz, duration_ms)
1085
+ var signal_specs: Array = data[3] if data.size() > 3 else []
1086
+ var start_result := _sampler.start(specs, hz, duration_ms, signal_specs)
1067
1087
  EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {
1068
1088
  "started": true,
1069
1089
  "resolved_fields": start_result.get("resolved_fields", 0),
1090
+ "connected_signals": start_result.get("connected_signals", 0),
1091
+ "unresolved_signals": start_result.get("unresolved_signals", []),
1070
1092
  }])
1071
1093
 
1072
1094
 
1073
1095
  func _handle_watch_collect() -> void:
1074
1096
  if _sampler == null:
1075
- EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", {"window_ms": 0, "sample_count": 0, "fields": {}}])
1097
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", {"window_ms": 0, "sample_count": 0, "fields": {}, "events": [], "events_truncated": false}])
1076
1098
  return
1077
1099
  EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", _sampler.collect()])
1078
1100
 
1079
1101
 
1080
1102
  func _handle_watch_stop() -> void:
1081
1103
  if _sampler == null:
1082
- EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", {"window_ms": 0, "sample_count": 0, "fields": {}}])
1104
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", {"window_ms": 0, "sample_count": 0, "fields": {}, "events": [], "events_truncated": false}])
1083
1105
  return
1084
1106
  EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", _sampler.stop()])
1085
1107
 
@@ -1166,10 +1188,12 @@ func _event_to_string(event: InputEvent) -> String:
1166
1188
  return "Mouse Button %d" % mouse_event.button_index
1167
1189
  elif event is InputEventJoypadButton:
1168
1190
  var joy_event := event as InputEventJoypadButton
1169
- return "Joypad Button %d" % joy_event.button_index
1191
+ return "Joypad Button %d (%s)" % [joy_event.button_index, MCPJoyNames.button_name(joy_event.button_index)]
1170
1192
  elif event is InputEventJoypadMotion:
1193
+ # The signed axis_value is the direction bit an agent needs to lift the
1194
+ # binding straight into an injection (e.g. move_left = left_x, value -1.0).
1171
1195
  var joy_motion := event as InputEventJoypadMotion
1172
- return "Joypad Axis %d" % joy_motion.axis
1196
+ return "Joypad Axis %d (%s, value %+.1f)" % [joy_motion.axis, MCPJoyNames.axis_name(joy_motion.axis), joy_motion.axis_value]
1173
1197
  return event.as_text()
1174
1198
 
1175
1199
 
@@ -1179,6 +1203,11 @@ func _handle_execute_input_sequence(data: Array) -> void:
1179
1203
  var screenshot_offsets: Array = data[2] if data.size() > 2 and data[2] is Array else []
1180
1204
  var cap_max_width: int = int(data[3]) if data.size() > 3 else 640
1181
1205
 
1206
+ # Reset the skew echo up front: the result's input_kinds must reflect THIS
1207
+ # call's compile, never a stale value from a prior sequence (its absence is
1208
+ # how a new server detects an old bridge — a stale dict would mask that).
1209
+ _sequence_input_kinds = {"action": 0, "joy_button": 0, "axis": 0}
1210
+
1182
1211
  if inputs.is_empty():
1183
1212
  EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
1184
1213
  "error": "No inputs provided",
@@ -1230,34 +1259,14 @@ func _handle_execute_input_sequence(data: Array) -> void:
1230
1259
  _sequence_captures_pending = 0
1231
1260
  _sequence_capture_max_width = cap_max_width
1232
1261
 
1233
- for input in inputs:
1234
- var action_name: String = input.get("action_name", "")
1235
- var start_ms: int = int(input.get("start_ms", 0))
1236
- var duration_ms: int = int(input.get("duration_ms", 0))
1237
-
1238
- if action_name.is_empty():
1239
- continue
1240
-
1241
- if not InputMap.has_action(action_name):
1242
- EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
1243
- "error": "Unknown action: %s" % action_name,
1244
- }])
1245
- return
1246
-
1247
- _sequence_events.append({
1248
- "time": start_ms,
1249
- "action": action_name,
1250
- "is_press": true,
1251
- })
1252
- _sequence_events.append({
1253
- "time": start_ms + duration_ms,
1254
- "action": action_name,
1255
- "is_press": false,
1256
- })
1257
-
1258
- _sequence_events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
1259
- return a.time < b.time
1260
- )
1262
+ var compiled := _compile_input_events(inputs)
1263
+ if compiled.has("error"):
1264
+ EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
1265
+ "error": compiled["error"],
1266
+ }])
1267
+ return
1268
+ _sequence_events = compiled["events"]
1269
+ _sequence_input_kinds = compiled["kinds"]
1261
1270
 
1262
1271
  # Baseline the effect probe at the last possible moment before any input fires.
1263
1272
  _sequence_report = report_compiled
@@ -1511,6 +1520,10 @@ func _handle_game_time_step(data: Array) -> void:
1511
1520
  _send_game_time_response("game_time_step", {"error": "Step already in progress"})
1512
1521
  return
1513
1522
 
1523
+ # Reset the skew echo up front (see _handle_execute_input_sequence): the
1524
+ # step result's input_kinds must reflect this call, never a prior step's.
1525
+ _step_input_kinds = {"action": 0, "joy_button": 0, "axis": 0}
1526
+
1514
1527
  var duration_ms: int = int(params.get("duration_ms", 0))
1515
1528
  var frames: int = int(params.get("frames", 0))
1516
1529
  if duration_ms <= 0 and frames <= 0:
@@ -1523,10 +1536,11 @@ func _handle_game_time_step(data: Array) -> void:
1523
1536
  # from window start). Inputs must ride inside the step: an event injected
1524
1537
  # while frozen lands on a frame gameplay never processes, so its
1525
1538
  # is_action_just_pressed edge would be silently missed.
1526
- var compiled := _compile_step_events(params.get("inputs", []))
1539
+ var compiled := _compile_input_events(params.get("inputs", []))
1527
1540
  if compiled.has("error"):
1528
1541
  _send_game_time_response("game_time_step", {"error": compiled["error"]})
1529
1542
  return
1543
+ _step_input_kinds = compiled["kinds"]
1530
1544
 
1531
1545
  # Step from a running game is allowed — it freezes first, so "advance
1532
1546
  # 500ms then wait for me" is a single atomic call.
@@ -1561,24 +1575,136 @@ func _handle_game_time_step(data: Array) -> void:
1561
1575
  _update_processing()
1562
1576
 
1563
1577
 
1564
- func _compile_step_events(inputs: Array) -> Dictionary:
1565
- # Builds the press/release timeline shared by step and step_until. start_ms
1566
- # is game time from window start; returns {"error": ...} on an unknown action.
1578
+ func _compile_input_events(inputs: Array) -> Dictionary:
1579
+ # Builds the typed input timeline shared by input sequences and game-time
1580
+ # steps (#233). Each entry is discriminated by which key it carries:
1581
+ # `axis` (analog joypad axis), `joy_button`, or `action_name` (with optional
1582
+ # fractional `strength`). Returns {"events": [...], "kinds": {...}} or
1583
+ # {"error": ...} on an unknown action/button/axis. Every event carries:
1584
+ # time - ms offset from sequence/window start
1585
+ # phase - 0 = release/zero-set, 1 = press/set (the equal-time tie-break)
1586
+ # complete - completion credit, counted when the event fires (1 on ends)
1567
1587
  var events: Array = []
1588
+ var kinds := {"action": 0, "joy_button": 0, "axis": 0}
1568
1589
  for input in inputs:
1569
- var action_name: String = input.get("action_name", "")
1570
- if action_name.is_empty():
1571
- continue
1572
- if not InputMap.has_action(action_name):
1573
- return {"error": "Unknown action: %s" % action_name}
1574
1590
  var start_ms: int = int(input.get("start_ms", 0))
1575
1591
  var dur: int = int(input.get("duration_ms", 0))
1576
- events.append({"time": start_ms, "action": action_name, "is_press": true})
1577
- events.append({"time": start_ms + dur, "action": action_name, "is_press": false})
1592
+ # An instant tap (duration 0 the schema default) must still emit its end
1593
+ # event STRICTLY AFTER its start, or the equal-time (time, phase) sort below
1594
+ # orders the release/zero-set before the press/set and the input latches
1595
+ # (press never paired with a release). One ms is enough: the time sort then
1596
+ # fires start-before-end even when both land in the same process frame.
1597
+ var end_ms: int = start_ms + maxi(dur, 1)
1598
+ if input.has("axis"):
1599
+ var axis := MCPJoyNames.axis_index(input["axis"])
1600
+ if axis < 0:
1601
+ return {"error": "Unknown joypad axis: %s (valid: %s)" % [str(input["axis"]), ", ".join(MCPJoyNames.AXES.keys())]}
1602
+ var device: int = int(input.get("device", 0))
1603
+ var value: float = clampf(float(input.get("value", 0.0)), -1.0, 1.0)
1604
+ kinds["axis"] += 1
1605
+ events.append({"time": start_ms, "phase": 1, "complete": 0,
1606
+ "kind": "axis", "axis": axis, "device": device, "value": value})
1607
+ events.append({"time": end_ms, "phase": 0, "complete": 1,
1608
+ "kind": "axis", "axis": axis, "device": device, "value": 0.0})
1609
+ elif input.has("joy_button"):
1610
+ var button := MCPJoyNames.button_index(input["joy_button"])
1611
+ if button < 0:
1612
+ return {"error": "Unknown joypad button: %s (valid: %s, or a raw index)" % [str(input["joy_button"]), ", ".join(MCPJoyNames.BUTTONS.keys())]}
1613
+ var bdevice: int = int(input.get("device", 0))
1614
+ kinds["joy_button"] += 1
1615
+ events.append({"time": start_ms, "phase": 1, "complete": 0,
1616
+ "kind": "joy_button", "button": button, "device": bdevice, "is_press": true})
1617
+ events.append({"time": end_ms, "phase": 0, "complete": 1,
1618
+ "kind": "joy_button", "button": button, "device": bdevice, "is_press": false})
1619
+ else:
1620
+ var action_name: String = input.get("action_name", "")
1621
+ if action_name.is_empty():
1622
+ continue
1623
+ if not InputMap.has_action(action_name):
1624
+ return {"error": "Unknown action: %s" % action_name}
1625
+ var strength: float = clampf(float(input.get("strength", 1.0)), 0.0, 1.0)
1626
+ kinds["action"] += 1
1627
+ events.append({"time": start_ms, "phase": 1, "complete": 0,
1628
+ "kind": "action", "action": action_name, "strength": strength, "is_press": true})
1629
+ events.append({"time": end_ms, "phase": 0, "complete": 1,
1630
+ "kind": "action", "action": action_name, "strength": strength, "is_press": false})
1631
+ # Releases/zero-sets fire before presses/sets at equal time, so a same-time
1632
+ # axis zero can never clobber a follow-on set of the same axis.
1578
1633
  events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
1579
- return a.time < b.time
1634
+ if a.time != b.time:
1635
+ return a.time < b.time
1636
+ return a.phase < b.phase
1580
1637
  )
1581
- return {"events": events}
1638
+ _cancel_redundant_axis_zeroes(events)
1639
+ return {"events": events, "kinds": kinds}
1640
+
1641
+
1642
+ func _cancel_redundant_axis_zeroes(events: Array) -> void:
1643
+ # Abutting same-axis entries (sweep ramps) must not bounce through zero:
1644
+ # both the zero-set and the next set pop in the same frame's while-loop, so
1645
+ # sort order alone cannot hide the transient zero from InputMap edge
1646
+ # detection (is_action_just_released would fire mid-sweep). Drop a zero-set
1647
+ # that lands at the same time as a follow-on set of the same (device, axis),
1648
+ # moving its completion credit to the survivor so counts stay exact.
1649
+ # Actions and buttons are deliberately NOT cancelled: release+press at the
1650
+ # same ms is a legitimate double-tap edge.
1651
+ var i := 0
1652
+ while i < events.size():
1653
+ var ev: Dictionary = events[i]
1654
+ if ev.kind == "axis" and ev.phase == 0:
1655
+ var j := i + 1
1656
+ while j < events.size() and events[j].time == ev.time:
1657
+ var nx: Dictionary = events[j]
1658
+ if nx.kind == "axis" and nx.phase == 1 and nx.axis == ev.axis and nx.device == ev.device:
1659
+ nx.complete = int(nx.complete) + int(ev.complete)
1660
+ events.remove_at(i)
1661
+ i -= 1
1662
+ break
1663
+ j += 1
1664
+ i += 1
1665
+
1666
+
1667
+ func _inject_timeline_event(ev: Dictionary) -> int:
1668
+ # Build and parse the engine event for one timeline entry, maintaining the
1669
+ # held-state registries that _release_held_actions uses for guaranteed
1670
+ # cleanup. Returns the event's completion credit. Joypad events drive the
1671
+ # polled Input singletons too (get_joy_axis / is_joy_button_pressed): unlike
1672
+ # the mouse cursor, parse_input_event updates joypad state for any device id
1673
+ # with no physical pad required.
1674
+ match str(ev.kind):
1675
+ "action":
1676
+ var ae := InputEventAction.new()
1677
+ ae.action = ev.action
1678
+ ae.pressed = ev.is_press
1679
+ ae.strength = float(ev.strength) if ev.is_press else 0.0
1680
+ Input.parse_input_event(ae)
1681
+ if ev.is_press:
1682
+ _held_actions[ev.action] = true
1683
+ else:
1684
+ _held_actions.erase(ev.action)
1685
+ "joy_button":
1686
+ var be := InputEventJoypadButton.new()
1687
+ be.device = ev.device
1688
+ be.button_index = ev.button as JoyButton
1689
+ be.pressed = ev.is_press
1690
+ Input.parse_input_event(be)
1691
+ var bkey := "%d:%d" % [ev.device, ev.button]
1692
+ if ev.is_press:
1693
+ _held_joy_buttons[bkey] = {"device": ev.device, "button": ev.button}
1694
+ else:
1695
+ _held_joy_buttons.erase(bkey)
1696
+ "axis":
1697
+ var me := InputEventJoypadMotion.new()
1698
+ me.device = ev.device
1699
+ me.axis = ev.axis as JoyAxis
1700
+ me.axis_value = ev.value
1701
+ Input.parse_input_event(me)
1702
+ var akey := "%d:%d" % [ev.device, ev.axis]
1703
+ if absf(float(ev.value)) > 0.0001:
1704
+ _active_axes[akey] = {"device": ev.device, "axis": ev.axis}
1705
+ else:
1706
+ _active_axes.erase(akey)
1707
+ return int(ev.get("complete", 0))
1582
1708
 
1583
1709
 
1584
1710
  func _build_predicate_context() -> Dictionary:
@@ -1657,6 +1783,9 @@ func _handle_game_time_step_until(data: Array) -> void:
1657
1783
  _send_game_time_response("game_time_step_until", {"error": "Step already in progress"})
1658
1784
  return
1659
1785
 
1786
+ # Reset the skew echo up front (see _handle_execute_input_sequence).
1787
+ _step_input_kinds = {"action": 0, "joy_button": 0, "axis": 0}
1788
+
1660
1789
  var src: String = str(params.get("until", "")).strip_edges()
1661
1790
  if src.is_empty():
1662
1791
  _send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
@@ -1690,14 +1819,17 @@ func _handle_game_time_step_until(data: Array) -> void:
1690
1819
  return
1691
1820
  var report_compiled: Array = report_result["report"]
1692
1821
 
1693
- var compiled := _compile_step_events(params.get("inputs", []))
1822
+ var compiled := _compile_input_events(params.get("inputs", []))
1694
1823
  if compiled.has("error"):
1695
1824
  _send_game_time_response("game_time_step_until", {"error": compiled["error"]})
1696
1825
  return
1826
+ _step_input_kinds = compiled["kinds"]
1697
1827
 
1698
1828
  _engage_freeze()
1699
1829
 
1700
1830
  # Predicate already holds: advance nothing, stay frozen, report it.
1831
+ # input_kinds still rides along — its absence is the version-skew signal a
1832
+ # new server reads, so every success shape must carry it.
1701
1833
  if bool(first_value):
1702
1834
  var sc_result: Dictionary = {
1703
1835
  "completed": true,
@@ -1708,6 +1840,7 @@ func _handle_game_time_step_until(data: Array) -> void:
1708
1840
  "physics_ticks": 0,
1709
1841
  "game_paused": _game_paused,
1710
1842
  "predicate_met": true,
1843
+ "input_kinds": _step_input_kinds,
1711
1844
  }
1712
1845
  if not report_compiled.is_empty():
1713
1846
  sc_result["report"] = _evaluate_report(report_compiled, ctx_inputs)
@@ -1769,17 +1902,9 @@ func _step_process(delta: float) -> void:
1769
1902
 
1770
1903
  while _step_events.size() > 0 and _step_events[0].time <= _step_elapsed_ms:
1771
1904
  var ev: Dictionary = _step_events.pop_front()
1772
- var input_event := InputEventAction.new()
1773
- input_event.action = ev.action
1774
- input_event.pressed = ev.is_press
1775
- input_event.strength = 1.0 if ev.is_press else 0.0
1776
- Input.parse_input_event(input_event)
1905
+ _inject_timeline_event(ev)
1777
1906
  _step_events_fired += 1
1778
1907
  _step_needs_settle = true
1779
- if ev.is_press:
1780
- _held_actions[ev.action] = true
1781
- else:
1782
- _held_actions.erase(ev.action)
1783
1908
 
1784
1909
  var done := false
1785
1910
  if _step_target_frames > 0:
@@ -1819,7 +1944,7 @@ func _step_process(delta: float) -> void:
1819
1944
  func _finish_step() -> void:
1820
1945
  # Releases are guaranteed cleanup, never queued steps: no holds survive
1821
1946
  # across the freeze boundary (cross-step holds are a deliberate non-goal).
1822
- var forced := _held_actions.size()
1947
+ var forced := _held_actions.size() + _held_joy_buttons.size() + _active_axes.size()
1823
1948
  _release_held_actions()
1824
1949
  var dropped := _step_events.size()
1825
1950
  _step_events.clear()
@@ -1839,6 +1964,7 @@ func _finish_step() -> void:
1839
1964
  "frames": _step_frames,
1840
1965
  "physics_ticks": _step_physics_ticks,
1841
1966
  "game_paused": _game_paused,
1967
+ "input_kinds": _step_input_kinds,
1842
1968
  }
1843
1969
  if _step_events_fired > 0:
1844
1970
  result["events_fired"] = _step_events_fired