godot-cli-control 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/.gitignore +3 -0
  2. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/PKG-INFO +1 -1
  3. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/CHANGELOG.md +32 -0
  4. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/README.md +17 -8
  5. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/error_codes.gd +18 -0
  6. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/game_bridge.gd +116 -4
  7. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/input_simulation_api.gd +154 -0
  8. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/low_level_api.gd +410 -58
  9. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/wait_api.gd +82 -20
  10. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +142 -0
  11. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +237 -0
  12. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +759 -0
  13. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_wait_api.gd +151 -27
  14. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/_version.py +2 -2
  15. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/bridge.py +100 -9
  16. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/cli.py +681 -25
  17. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/client.py +305 -22
  18. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/daemon.py +76 -3
  19. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/templates/skill/SKILL.md +81 -28
  20. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_bridge.py +179 -4
  21. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_cli.py +972 -11
  22. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_cli_helpers.py +57 -7
  23. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_client.py +879 -1
  24. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_daemon.py +262 -4
  25. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_client_direct.py +203 -0
  26. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_record.py +102 -1
  27. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_screenshot_gui.py +53 -0
  28. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/LICENSE +0 -0
  29. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/LICENSE +0 -0
  30. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  31. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  32. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/capture_logger.gd +0 -0
  33. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/diagnostics_api.gd +0 -0
  34. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  35. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  36. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  37. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/render_api.gd +0 -0
  38. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/scene_api.gd +0 -0
  39. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/time_api.gd +0 -0
  40. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/variant_codec.gd +0 -0
  41. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/plugin.cfg +0 -0
  42. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/plugin.gd +0 -0
  43. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/plugin.gd.uid +0 -0
  44. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_diagnostics_api.gd +0 -0
  45. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_render_api.gd +0 -0
  46. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_scene_api.gd +0 -0
  47. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_time_api.gd +0 -0
  48. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_variant_codec.gd +0 -0
  49. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/run_gut.py +0 -0
  50. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  51. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  52. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  53. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/pyproject.toml +0 -0
  54. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/README.md +0 -0
  55. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/__init__.py +0 -0
  56. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/__main__.py +0 -0
  57. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/_duration.py +0 -0
  58. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/init_cmd.py +0 -0
  59. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/pytest_plugin.py +0 -0
  60. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/registry.py +0 -0
  61. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/runner.py +0 -0
  62. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/skills_install.py +0 -0
  63. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/templates/__init__.py +0 -0
  64. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  65. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/__init__.py +0 -0
  66. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/conftest.py +0 -0
  67. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_broadcast.py +0 -0
  68. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_duration.py +0 -0
  69. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_diag.py +0 -0
  70. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_example.py +0 -0
  71. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_input.py +0 -0
  72. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_multi_instance.py +0 -0
  73. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_render.py +0 -0
  74. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_scene.py +0 -0
  75. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_time.py +0 -0
  76. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_init.py +0 -0
  77. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_issue_fixes.py +0 -0
  78. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_pytest_plugin.py +0 -0
  79. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_registry.py +0 -0
  80. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_runner.py +0 -0
  81. {godot_cli_control-0.3.0 → godot_cli_control-0.4.1}/python/tests/test_skills_install.py +0 -0
@@ -33,3 +33,6 @@ Thumbs.db
33
33
 
34
34
  # CLI control runtime
35
35
  .cli_control/
36
+
37
+ # Claude Code 隔离 worktree(harness 创建的临时工作目录,不入库;.claude/skills 渲染版 SKILL.md 仍入库)
38
+ .claude/worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Project-URL: Homepage, https://github.com/ClaymanTwinkle/godot-cli-control
6
6
  Project-URL: Repository, https://github.com/ClaymanTwinkle/godot-cli-control
@@ -6,6 +6,38 @@
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.1] - 2026-06-14
10
+
11
+ ### Fixed
12
+ - **#167 `call` 对 typed 参数不再假成功,且复用 `set` 的 Array→复合 Variant coerce**:此前 `handle_call_method` 裸 `node.callv(method, args)`,当方法形参是强类型(`Rect2`/`Vector2`/`Color` 等)而 CLI 传 JSON Array 时,Godot 只喷 `Cannot convert argument ...` 引擎错误并返回 `null`,RPC 仍假 `ok:true`/`result:null`——调用方以为方法生效实则没跑(#164 的 live `WANDER` 演示就栽在这)。GDScript 无 try-catch 抓不到 callv 运行期错误,现改为连 daemon 前按方法声明签名(`get_method_list()`)预校验:① JSON Array 喂复合 Variant 形参时按声明类型 coerce(`call /root/Mob enable_wander '[0,0,640,480]'` → `enable_wander(rect: Rect2)`),复用 `set` 那套 `_coerce_compound_array`(长度/元素错同样 `-32602` fail-loud);② Array 喂标量/Object 形参(callv 必转换失败)→ `-32602` fail-loud;③ arg 数量越界(认可选/默认参数与 vararg)→ `-32602` fail-loud。标量实参(数字/字符串/布尔)与拿不到签名的内建方法保持原样透传,零回归。纯 addon 服务端行为改动,老项目跑一次 `init` 同步。
13
+
14
+ ## [0.4.0] - 2026-06-12
15
+
16
+ ### Added
17
+ - **`wait-signal --trigger`:同一连接 arm→触发→等信号,消灭 shell 后台三步模板与竞态(#155)**:`wait-signal <path> <signal> [timeout] --trigger '<subcommand>'` 在服务端 connect 信号处理器后先发 `armed` 中间帧,客户端收到后在同连接执行 trigger 子命令,再等信号终帧——arm 与触发之间零竞态窗口。命中信封新增 `trigger_result` 字段(trigger 子命令的返回值)。`--trigger` 接受任意 RPC 子命令(多步用 `combo`);非 RPC 命令(`daemon`/`run`/`init`)及嵌套 `wait-*` 在 preflight 阶段(-1003/64)拒绝。需新 addon 能力(`arm_ack` / `armed` 帧协议)→ 老项目跑一次 `init` 同步。
18
+ - **坐标级鼠标注入 `click-at` / `mouse-move`**(#154):`godot-cli-control click-at <x> <y> [--button left|right|middle] [--double]` 注入 down→up 鼠标点击,`mouse-move <x> <y>` 注入带 `relative` 的移动事件;两者都支持 `--node <path>` 取节点屏幕中心点(复用 `screenshot --node` 的坐标变换,hiDPI / stretch 窗口自洽)。坐标用 **viewport 物理像素**。区别于 `click`(节点级 UI 点击、需预知目标):经 `Viewport.push_input` 走真实事件管线,能命中依赖光标位置的 `_gui_input` 控件(自定义 shape 的 `TextureButton`、`TouchScreenButton`、世界坐标 `Area2D` 拾取)。坐标与 `--node` 二选一、连接前 preflight 校验(-1003/64)。配套 `GameClient.click_at()/mouse_move()` 与 `GameBridge.click_at()/mouse_move()`。注意:事件的 `position`/`relative`/`button_mask` 对事件回调有效,但**不**更新 `Input` 单例的全局鼠标轮询(`get_global_mouse_position` / `is_mouse_button_pressed`)——读鼠标态请从事件参数读。需要新 RPC `click_at`/`mouse_move`——老 addon 项目跑一次 `init` 同步。
19
+ - **坐标级拖拽 `drag`**(#154 P2):`godot-cli-control drag <x1> <y1> <x2> <y2> [--button left|right|middle] [--duration 0.3] [--steps 10]` 在起点按下鼠标键、按 `duration` 分 `steps` 段插值移动(移动事件全程带住按键 mask)、在终点松开——滑块拖动 / 拖放 / 滑动手势的原语。两端各自支持 `--from-node` / `--to-node` 取节点屏幕中心点(与该端坐标二选一,坐标数 0/2/4 按内容消歧,连接前 preflight 校验 -1003/64)。`duration` 是 game-time(受 `Engine.time_scale`,与 combo 同语义);服务端 async 协程逐帧推进,客户端用长操作生死线(同 combo,受 `GODOT_CLI_LONG_OP_TIMEOUT`)。同一时刻只允许一个 drag 在途,再发回 `1014 DRAG_IN_PROGRESS`;`release-all` 会取消在途 drag 并补一个 mouse-up(避免被拖控件卡在「拖拽中」)。事件 `position`/`relative`/`button_mask` 对事件回调有效,但**不**更新 `Input` 单例轮询态(同 `click-at`/`mouse-move`)。配套 `GameClient.drag()` / `GameBridge.drag()`。需要新 RPC `drag`——老 addon 项目跑一次 `init` 同步。
20
+ - `tree` 新增可选 path 位置参数:`tree /root/GameUI [depth]` dump 任意子树,含 autoload 挂 `/root` 的兄弟(此前只能看 current_scene);第一个参数以 `/` 开头当路径否则当 depth,`tree <depth>` 旧用法不变;路径不存在报 1001、用法错报 64(#150)。
21
+ - **`find` 服务端节点搜索**(#153):`godot-cli-control find [--from <path>] [--type <Class>] [--exact <text>|--contains <substr>] [--name-pattern <glob>] [--limit N]`——单次 RPC 服务端遍历替代客户端 `children`+`text` 逐层递归(程序化匿名 UI `@Button@12` 按文本定位的原语;录制模式下每个 RPC 等帧渲染 50-150ms,一次全树遍历曾拖出 57s 死时间,现折成一次往返)。过滤器 AND 语义至少给一个(连接前 preflight,-1003/64);`--type` 按继承匹配且认 `class_name` 脚本类;`--exact`/`--contains` 是 `text` 属性的精确/子串两档(互斥;精确档不叫 `--text` 因与全局输出 flag 撞名);matches 按 BFS 浅层优先,超 `--limit`(默认 20,服务端上限 500)附 `truncated: true`(tree 同款信号)。退出码 0=有匹配、1=零匹配(shell `if` 可用)。配套 `GameClient.find_nodes()` / `GameBridge.find_nodes()`;entry 形状与 `tree` 对齐(`{name,type,path,text?,visible?}`)。需要新 RPC `find_nodes`——老 addon 项目跑一次 `init` 同步(未同步时报 `-32601`)。
22
+ - **screenshot `stale_suspect` 风险信号 + `--no-always-on-top`**(#156 子问题 B):`screenshot` 信封新增可选 `stale_suspect: true`——本次截图字节与上一次完全相同(**可能** stale 也可能游戏真静止,风险提示非确定断言),agent 据此决定重试 / force。`daemon start --record` / `run --record` 新增 `--no-always-on-top`(不置顶,关)。
23
+ - **`run` 透传 `--time-scale`**(#157):`run <script> --time-scale N` 与 `daemon start --time-scale` 对称,启动即设 `Engine.time_scale`(范围 (0,100]),透传给自动启动的 daemon——消除「run 模式只能在脚本首行 `bridge.time_scale(N)`」的不对称 workaround。
24
+ - **RPC 子命令的 `--port` / `--instance` 可后置**(#157):`<subcommand> ... --port N` 与原来的 `--port N <subcommand>` 现在都接受(复用 `--json`/`--text` 的位置自由化机制,`default=argparse.SUPPRESS`),消灭「顶层 `--port` 必须写在子命令前」的 pitfall。`--port` 与 `--instance` 跨位置同给(一前一后)由 preflight guard 兜成 `-1003` / exit 64。
25
+ - **`emit-signal` 子命令 + `--allow-emit-signal` 逃生门**(#157 item4):`godot-cli-control emit-signal <path> <signal> [args...]` 发射节点信号——测试里信号常是唯一接缝(如 `ItemList.select()` 不发 `item_selected`)。默认禁(服务端 `1015`),需 `daemon start` / `run` 带 `--allow-emit-signal` 显式 opt-in(debug-build + localhost 之上第三重门)。`emit_signal` 仍在方法黑名单里,`call <node> emit_signal` 始终被拒——只放开这一个目的明确、被门控的入口,不松动整张黑名单。args 同 `call`(逐个 JSON-or-string,`--text-value` 强制字符串)。需新 RPC `emit_signal`——老 addon 项目跑一次 `init` 同步(未同步时报 `-32601`)。配套 `GameClient.emit_signal()` / `GameBridge.emit_signal()`。
26
+
27
+ ### Changed
28
+ - **`daemon stop` 优雅退出,录制尾帧不再丢**(#156 子问题 A):`daemon stop` 现在在 SIGTERM 之前先经内部 `quit` RPC 让 Godot 正常退出,Movie Maker 在正常 quit 路径上 flush AVI 写缓冲——消除 SIGTERM 直杀丢 4-6s 尾帧的问题(下游录 demo 不必再垫 `wait(4.0)` 牺牲段)。RPC 不通(daemon 挂死 / 连不上 / 超时 / 进程不退)自动无缝降级原 SIGTERM→SIGKILL 路径,退出码语义不变。`quit` 是 stop 的内部 RPC,不单列 CLI 子命令。需要新 addon RPC——老项目跑一次 `init` 同步。
29
+ - **macOS 遮挡冻帧防护**(#156 子问题 B):`daemon start --record` 现在默认置窗口 `always_on_top`(macOS 对被遮挡窗口节流渲染 → Movie Maker 写 stale 帧 / 截图拿旧画面,置顶根治;`--no-always-on-top` 关);`screenshot` 服务端抓帧前 `RenderingServer.force_draw()` 主动出新帧绕过遮挡节流。下游不再需要 `set always_on_top` + osascript / `grab_focus` + `wait-frames` 用户侧 workaround。需要新 addon 行为——老项目跑一次 `init` 同步。
30
+ - **转码失败专用退出码 `4`**(#157):`daemon stop` / `run` 在「进程已正常停止、原始 AVI 保留、仅 ffmpeg 转 mp4 失败」时返回 exit **4**(此前与连接失败 / daemon 起不来共用过载的 exit 2)——脚本可用 `rc==4` 直接判「只差转码」,exit 2 留给真 infra 故障。信封仍 `ok:true` + `daemon_stop_warning`;`daemon stop --all` 聚合不计此项(仍 `0|3`,3 只在硬 stop 失败时),转码失败只反映在 per-entry `rc:4`。
31
+ - **`get` sub-path leaf typo 对封闭复合类型 fail-loud**(#157 + #169):`get <node> position:typo` 读封闭复合 Variant 的不存在 leaf 时现报 `1002 PROPERTY_NOT_FOUND`(message 列出合法 leaf),不再静默返回 `{"value": null}`(与真 null 不可区分的 footgun)。覆盖类型:Vector2/3/4(含 i 变体)、Color、Rect2/Rect2i、Transform2D/Transform3D、Basis、Plane、Quaternion、AABB、Projection——每类合法 leaf 集经 GUT 用 `get_indexed` 实证完整(基线 Godot 4.6.2),漏列即误杀合法读取故零猜测。嵌套子路径逐层校验(`transform:basis:typo`、`modulate:typo` 同样 fail-loud)。Dictionary/Array/Object 等开放/动态类型 key 不可枚举,保持现状 `get_indexed` 行为(零回归,误杀比静默更坏)。纯 addon 服务端行为改动,老项目跑一次 `init` 同步。
32
+ - **`set` sub-path leaf typo 对封闭复合类型 fail-loud**(#169 follow-up #174):`set <node> position:zz 5` 写封闭复合 Variant 的不存在 leaf 时现报 `1002 PROPERTY_NOT_FOUND`(message 列出合法 leaf),不再静默丢值返 `{"success": true}`(agent 以为写成功实际没改的假成功 footgun)。复用 get 侧(#169)同一套 `_validate_sub_path_leaves` + `_SUBPATH_CLOSED_LEAVES`,覆盖类型与 leaf 集逐字相同,写前逐层校验、放行才 `set_indexed`;至此 set 与 get 行为对称。Dictionary/Array/Object 等开放/动态类型保持现状放行(零回归)。纯 addon 服务端行为改动,老项目跑一次 `init` 同步。
33
+
34
+ ### Fixed
35
+ - **#172 `wait-signal --trigger` 的 `timeout` 不再被 trigger 执行时间吞掉**:此前 timeout 从服务端发出 `armed` 帧即开始计时,而 trigger 在客户端执行(往返 + 游戏内 duration)期间已在消耗预算——慢 trigger(`combo` / `drag`)可能在信号发射前提前误超时。现协议增强:客户端在 trigger 完成后发 `wait_signal_start_timer` 通知服务端,timeout 改为从 trigger 完成后才开始计——只覆盖「等信号」而非「trigger 执行 + 等信号」。`armed` 中间帧同时改用正向 `kind:"armed"` 判据识别(保留 `armed` 布尔字段给旧 client 前向兼容)。改动在 addon(game_bridge.gd / wait_api.gd)+ 客户端——老项目跑一次 `init` 同步 addon。
36
+ - **#151 `run` 的 GUI auto-detect 覆盖 sibling import 的 helper 模块**:`run <script>` 此前只 grep 主脚本源码判断是否含 `screenshot`,但 `run` 官方支持脚本同目录的 sibling import(`from helpers import shoot`,目录在 `sys.path` 上)——screenshot 调用写在 helper 模块里时检测漏报,非 TTY 下 daemon 仍以 headless 启动、跑到截图处静默 `1006` fail。现解析主脚本的 import、对**同目录命中的 .py** 一并做子串检测(只扫一层不递归;stdlib / 第三方包不在同目录,`read_text` OSError 自然跳过)。纯客户端检测改动,无需 `init` 同步 addon。
37
+ - **#160 `_send_json` 发送失败不再静默 → 假 `-1002` 超时**:单条响应超出站 WebSocket 缓冲(默认 10MB,`godot_cli_control/outbound_buffer_mb` 可调)时,`send_text` 返回值此前被丢弃、daemon 静默不响应,client await 挂到 30s 报 `-1002 Timeout`(真根因「响应过大」完全不可见,同 #149 的「误导性错误」家族)。现 `_send_json` 检查发送结果:失败时 stderr 留痕(payload 字节数 + buffer 上限),并对带 id 的响应补发一条小 `1016 RESPONSE_TOO_LARGE` 错误信封指路(screenshot 传 path 走 daemon 直写 / 调大 outbound_buffer_mb);error 信封自身或无 id 的帧不补发(防递归)。触发面已被 #149 收窄到不传 path 的 bytes-API 巨图截图。新增服务端码 `1016`。改动在 addon(game_bridge.gd),老项目跑一次 `init` 同步即获此修复。
38
+ - **#149 大图 screenshot 不再误报 `-1001` 连接错误**:hiDPI / 4K 全屏截图的 base64 曾超出 WebSocket 客户端默认 1MB 消息上限,连接被 close 1009 关闭、却呈现为「随机连接失败」(暗色简单画面能过、复杂画面必挂)。三层修复:① 根治——`screenshot` 的 PNG 改由 **daemon 进程直接落盘**(CLI 把路径 resolve 成绝对路径并先建好父目录),图像字节不再过 WS,任意尺寸可截,顺带消灭 base64 编解码开销;旧 addon 不认新参数时自动回退本地写盘(跑一次 `init` 即同步)。② client 放开 `max_size` 上限(兜旧 addon 回退与 `bridge.screenshot()` bytes API)。③ 连接被关时错误信息带上 close code/reason,根因不再被笼统的 "Connection closed by server" 吞掉。新增服务端错误码 `1013 WRITE_FAILED`(daemon 写不进目标路径,区别于客户端 `-1004`)。`GameClient.screenshot_raw()` / `GameBridge.screenshot()` 新增 `path` 直写支持。截图前手动缩窗口的 workaround 可以删了。
39
+ - **#152 `--movie-path` 非 .avi/.png 时启动前拒绝(-1003 / exit 64)**:此前传 `.mp4` 等后缀 Godot 打 "Can't find movie writer" 后继续正常跑——脚本照常执行、exit 0,但什么都没录(假成功)。现 `daemon start` / `run` 的 `--record` 在 argparse 层校验扩展名(大小写不敏感),错误信息指路「传 .avi,stop 时自动转码出 .mp4」;直接调 `Daemon.start()` 的 API 路径同样拒绝(DaemonError)。
40
+
9
41
  ## [0.3.0] - 2026-06-07
10
42
 
11
43
  ### Added
@@ -83,16 +83,21 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
83
83
  | Method | Example |
84
84
  |---|---|
85
85
  | `click(path)` | `await client.click("/root/MyScene/Button")` |
86
+ | `click_at(x, y, node=None, button="left", double=False)` | `await client.click_at(320, 240)` or `client.click_at(node="/root/Slot", button="right")` — coordinate-level mouse click via the real event pipeline (viewport physical px); CLI: `click-at <x> <y> \| --node <path> [--button] [--double]` |
87
+ | `mouse_move(x, y, node=None)` | `await client.mouse_move(400, 300)` — inject one mouse-motion event (with `relative`); CLI: `mouse-move <x> <y> \| --node <path>` |
88
+ | `drag(x1, y1, x2, y2, from_node=None, to_node=None, button="left", duration=0.3, steps=10)` | `await client.drag(100, 100, 300, 200)` — press→interpolated move→release (slider/drag-and-drop/swipe); each end may use `from_node`/`to_node` for a node center; game-time `duration`, one drag at a time (`1014` otherwise); CLI: `drag <x1> <y1> <x2> <y2> \| --from-node/--to-node <path> [--button] [--duration] [--steps]` |
86
89
  | `get_property(path, property)` | `await client.get_property("/root/Player", "position")` — returns bare value; CLI `get` returns `{"value": ..., "type"?: ...}` shape |
87
90
  | `get_properties(path, properties)` | `await client.get_properties("/root/Player", ["position", "health"])` — multi-property atomic read; returns `{prop: bare_value, ...}` dict |
88
91
  | `set_property(path, property, value)` | `await client.set_property("/root/Player", "visible", False)` |
89
- | `call_method(path, method, args)` | `await client.call_method("/root/Player", "take_damage", [10])` |
92
+ | `call_method(path, method, args)` | `await client.call_method("/root/Player", "take_damage", [10])`. A JSON array passed for a parameter the method declares as a compound Variant (`Rect2`/`Vector2`/`Color`/…) is coerced from the method signature, same as `set_property` (`call_method("/root/Mob", "enable_wander", [[0,0,640,480]])` → `enable_wander(rect: Rect2)`); arg-count / type mismatches fail loud with `-32602` instead of a silent `ok:true`/`result:null` (#167). |
93
+ | `emit_signal(path, signal, args)` | `await client.emit_signal("/root/Game", "level_complete", [])` — fire a node signal (test seam). **Disabled by default** (server returns `1015`); daemon must be started with `--allow-emit-signal` (debug-build + localhost + explicit opt-in). `call <node> emit_signal` is always blocked by the method blacklist — use this method. CLI: `emit-signal <path> <signal> [args...]` |
90
94
  | `get_text(path)` | `await client.get_text("/root/UI/Label")` |
91
95
  | `node_exists(path)` | `await client.node_exists("/root/MyScene/Button")` |
92
96
  | `is_visible(path)` | `await client.is_visible("/root/UI/Panel")` |
93
97
  | `get_children(path, type_filter?)` | `await client.get_children("/root/Map", "Node2D")` |
94
- | `get_scene_tree(depth)` | `await client.get_scene_tree(depth=3)` |
95
- | `screenshot(node=None)` | `png_bytes = await client.screenshot()`; pass `node="/root/Game/Sprite"` to crop to that node's screen-space AABB; CLI: `screenshot <path> [--node <node-path>]` (errors: `1010` bounds undeterminable, `1011` off-screen) |
98
+ | `get_scene_tree(depth, max_nodes=None, path=None)` | `await client.get_scene_tree(depth=3, path="/root/GameUI")` — omit `path` → current scene; CLI: `tree [path] [depth]` |
99
+ | `find_nodes(node_type?, text?, text_contains?, name_pattern?, from_path?, limit=20)` | `await client.find_nodes(node_type="Button", text_contains="开始")` server-side single-traversal node search (#153): the way to locate programmatic anonymous UI (`@Button@12`) by type (subclass + `class_name` aware) / `text` property (exact or substring, mutually exclusive) / node-name glob; filters AND together, at least one required (else `-32602`); BFS order (shallowest first); returns `{matches: [{name,type,path,text?,visible?}], truncated?: true}` over `limit` (server cap 500); CLI: `find [--from ...] [--type ...] [--exact ...\|--contains ...] [--name-pattern ...] [--limit N]`, exit 0 = ≥1 match, 1 = none |
100
+ | `screenshot(node=None)` | `png_bytes = await client.screenshot()`; pass `node="/root/Game/Sprite"` to crop to that node's screen-space AABB; CLI: `screenshot <path> [--node <node-path>]` (errors: `1010` bounds undeterminable, `1011` off-screen, `1013` daemon can't write the path). `screenshot_raw(node=None, path=None)`: pass an **absolute** `path` to have the daemon write the PNG to disk itself — response is `{path, bytes}` metadata only, no base64 crosses the socket (#149); parent dir must already exist |
96
101
  | `sprite_info(path)` | `await client.sprite_info("/root/Game/Sprite")` — aggregate render-state query for `Sprite2D` / `AnimatedSprite2D` / `TextureRect` (texture, `effective_region`, `frame_texture`, flips, frame, modulate); headless-safe; CLI: `sprite-info <node-path>`; error `1010` for other node types |
97
102
  | `errors(since=0, limit=100)` | `await client.errors(since=marker)` — structured `push_error`/`push_warning` capture (ring of last 1000, cursor pagination via `marker`); needs Godot 4.5+ `Logger` (else `1012`); CLI: `errors [--since MARKER] [--limit N]` |
98
103
  | `wait_for_node(path, timeout)` | `await client.wait_for_node("/root/Boss", timeout=10.0)` |
@@ -116,11 +121,11 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
116
121
  | `unpause()` | `await client.unpause()` — resume scene tree; idempotent; CLI: `unpause` |
117
122
  | `step_frames(frames, physics=False)` | `await client.step_frames(10)` — while paused, advance exactly N frames (1..3600) then stop; CLI: `step-frames <n> [--physics]`; error `1009` if tree not paused |
118
123
 
119
- > **CLI note**: as a shell subcommand, `screenshot` **requires an output path** — `godot-cli-control screenshot /tmp/shot.png` and writes the PNG to that file. Returning base64 over stdout is intentionally unsupported, to keep large binary payloads out of an automating agent's context window.
124
+ > **CLI note**: as a shell subcommand, `screenshot` **requires an output path** — `godot-cli-control screenshot /tmp/shot.png`. The PNG is written by the **daemon process directly to disk** (#149), so image size is unlimited — no payload crosses the WebSocket, and hiDPI / 4K fullscreen captures work without shrinking the window. Returning base64 over stdout is intentionally unsupported, to keep large binary payloads out of an automating agent's context window.
120
125
 
121
126
  > **`--wait`**: `tap` / `hold` / `combo` return as soon as the input is armed, *before* the motion finishes. Add `--wait` (e.g. `hold run 1.5 --wait`) to block until the action's duration elapses (game-time) so the next read sees the settled state — equivalent to following the command with `wait-time <duration>` on the same connection.
122
127
 
123
- > **Event pipeline**: `press` / `tap` / `hold` / `combo` inject an `InputEventAction` through the engine's event pipeline — both polling (`is_action_pressed`, `get_vector`) **and** event callbacks (`_input`, `_unhandled_input`) see the input. `InputEventAction` carries no mouse coordinates; position-dependent `_gui_input` widgets need `click` instead.
128
+ > **Event pipeline**: `press` / `tap` / `hold` / `combo` inject an `InputEventAction` through the engine's event pipeline — both polling (`is_action_pressed`, `get_vector`) **and** event callbacks (`_input`, `_unhandled_input`) see the input. `InputEventAction` carries no mouse coordinates; for position-dependent `_gui_input` widgets use `click_at` / `mouse_move` / `drag` (coordinate-level, viewport physical pixels, via `Viewport.push_input`) or `click` (node-level). `drag` presses, interpolates motion over game-time `duration` with the button held, then releases — one at a time (`1014` otherwise), and `release_all` cancels an in-flight drag with a pending mouse-up. Note these don't update the global `Input` mouse-polling state — read `position` / `relative` from the event, not by polling.
124
129
 
125
130
  > **No-arg `GameClient()` / `GameBridge()`**: with no `port` argument they auto-discover the daemon's port from `.cli_control/instances/<name>/port` (falling back to `9877`). Pass `instance="server"` to target a named instance when multiple are running (explicit `port` always wins over `instance`). A no-arg call in a single-instance environment still works as before.
126
131
 
@@ -146,13 +151,17 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
146
151
  | `1010` | server | UNSUPPORTED_NODE_TYPE: `sprite-info` on a non-sprite node, or `screenshot --node` on a node whose bounds can't be determined. Schema-class — pick another node/command, don't retry. |
147
152
  | `1011` | server | NODE_NOT_ON_SCREEN: `screenshot --node` crop rect doesn't intersect the viewport (off-screen / zero size). State-class — bring the node into view, then retry. |
148
153
  | `1012` | server | FEATURE_UNAVAILABLE: the hosting engine lacks an API this RPC needs (currently: `errors` requires Godot 4.5+ `Logger`). Permanent for that engine — upgrade Godot, don't retry. |
154
+ | `1013` | server | WRITE_FAILED: the daemon process couldn't write the `screenshot` PNG to the requested path (parent dir missing / no permission). Distinct from `-1004` (CLI-side IO). Permanent — fix the path, don't retry. |
155
+ | `1014` | server | DRAG_IN_PROGRESS: a `drag` was issued while another is still interpolating. One mouse drag in flight at a time — wait for it (or `release-all` to cancel) before the next. State-class (like `1004`). |
156
+ | `1015` | server | EMIT_SIGNAL_DISABLED: `emit-signal` called but daemon was not started with `--allow-emit-signal`. Restart the daemon with that flag (debug-build + localhost + explicit opt-in). `emit_signal` remains in the method blacklist — `call <node> emit_signal` is always rejected regardless of this flag. |
157
+ | `1016` | server | RESPONSE_TOO_LARGE: a single response exceeded the outbound WebSocket buffer (default 10 MB, `godot_cli_control/outbound_buffer_mb`) and was replaced with this error instead of hanging the call to a `-1002` timeout. Usually a bytes-API `screenshot` of a hiDPI frame — pass a path (daemon writes to disk) or raise the buffer. |
149
158
  | `-32600` | server | Malformed JSON-RPC request |
150
159
  | `-32601` | server | Unknown method name |
151
- | `-32602` | server | Invalid params (incl. blocked methods/properties from the security blacklist, `set` value-type mismatch — e.g. `Vector2` property given an array of wrong length / non-numeric elements, or `hold` given `duration ≤ 0` — use `press` for an indefinite hold; also out-of-range values like a scene `timeout` outside `(0, 3600]` sent directly via `GameClient`) |
160
+ | `-32602` | server | Invalid params (incl. blocked methods/properties from the security blacklist, `set` value-type mismatch — e.g. `Vector2` property given an array of wrong length / non-numeric elements; `call` typed-arg coercion failure / argument-count mismatch — wrong array length, non-numeric element, a JSON array given to a scalar/Object parameter, or wrong arg count (#167); or `hold` given `duration ≤ 0` — use `press` for an indefinite hold; also out-of-range values like a scene `timeout` outside `(0, 3600]` sent directly via `GameClient`, and `find_nodes` called with no filter at all or with both `text` and `text_contains`) |
152
161
  | `-1001` | client | Connection failure (daemon not running, port wrong, proxy hijacking localhost) |
153
162
  | `-1002` | client | Timeout waiting for response |
154
163
  | `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, a non-numeric `tap`/`wait-time` arg, a `scene-change` path not starting with `res://`/`uid://`, a `scene-reload`/`scene-change` `--timeout` outside `(0, 3600]`, a `set`/`call` value that fails JSON parsing, script path not found, script missing `run(bridge)`, **multi-instance ambiguity** (≥2 instances running and no `--instance`/`--name` given), **named instance not running** (explicit `--instance nope` but that instance isn't up), **instance port not readable yet** (alive but mid-startup — transient, retry), or `--instance` / `--name` conflict). Always exits **64** (#82 / #111). |
155
- | `-1004` | client | Local file IO error (e.g. screenshot can't write the destination) |
164
+ | `-1004` | client | Local file IO error in the CLI process (e.g. screenshot can't create the destination's parent dir); daemon-side write failures are `1013` |
156
165
  | `-1005` | client | `run <script>` user script raised an uncaught exception — fix the script |
157
166
  | `-1006` | client | Infra pre-condition failure (`daemon start`/`stop`/`run` auto-start failed at OS level — port conflict, Godot binary not found, etc.). Always exits **2** (#92). |
158
167
  | `-1099` | client | Internal CLI bug — please file an issue |
@@ -165,7 +174,7 @@ The daemon is the long-lived Godot process the CLI talks to (one per project roo
165
174
 
166
175
  | Command | What it does |
167
176
  |---|---|
168
- | `daemon start [--headless\|--gui] [--record] [--time-scale N] [--name <inst>]` | Launch Godot with the bridge listening. `--name` sets the instance name (default `default`); use different names to run multiple Godot daemons for the same project in parallel. Headless vs GUI is auto-detected from the TTY; `--record` forces GUI (Movie Maker needs a real renderer) and is **rejected with `--headless`** (exit 2). `--time-scale N` sets `Engine.time_scale` from frame 0 (range `(0, 100]`). |
177
+ | `daemon start [--headless\|--gui] [--record] [--time-scale N] [--name <inst>] [--allow-emit-signal]` | Launch Godot with the bridge listening. `--name` sets the instance name (default `default`); use different names to run multiple Godot daemons for the same project in parallel. Headless vs GUI is auto-detected from the TTY; `--record` forces GUI (Movie Maker needs a real renderer) and is **rejected with `--headless`** (exit 2). `--time-scale N` sets `Engine.time_scale` from frame 0 (range `(0, 100]`). `--allow-emit-signal` unlocks the `emit-signal` subcommand (debug-build + localhost + explicit opt-in three gates); without it any `emit-signal` call returns `1015`. |
169
178
  | `daemon stop [--name <inst>]` | Stop this project's daemon. With `--name`, stop the named instance. Exit 2 if it stopped cleanly but the `.avi`→`.mp4` ffmpeg transcode failed (raw `.avi` kept; see `.cli_control/ffmpeg.log`). |
170
179
  | `daemon stop --all` | Stop every registered daemon across all projects. **Exit 3** if at least one failed; per-record `rc` (and an `error` field on failures) is in `result.stopped[]`. |
171
180
  | `daemon stop --all --project <path>` | Stop all instances of one specific project. |
@@ -44,6 +44,24 @@ const NODE_NOT_ON_SCREEN: int = 1011
44
44
  # 老引擎上该 RPC 永久不可用。与 1006 区分:不是 transient,升级引擎前
45
45
  # 重试无意义;与 1010 区分:错的不是目标节点,是宿主引擎版本。
46
46
  const FEATURE_UNAVAILABLE: int = 1012
47
+ # screenshot 服务端落盘失败(issue #149):path 打不开(父目录不存在 /
48
+ # 无写权限 / 路径非法)。与客户端 -1004 区分:那是 CLI 进程本地写不进,
49
+ # 这是 daemon 进程写不进。永久错(与 1002/1003 同族)——修路径前重试无意义。
50
+ const WRITE_FAILED: int = 1013
51
+ # drag 互斥(issue #154 P2):已有一个 drag 协程在插值中又收到 drag 请求。
52
+ # 状态类错(与 1004 COMBO_IN_PROGRESS 同族)——同一时刻只允许一个鼠标拖拽
53
+ # 在途,agent 应等上一个完成(或 release-all 取消)再发。
54
+ const DRAG_IN_PROGRESS: int = 1014
55
+ # emit-signal 逃生门未开(issue #157 item4):daemon 未带 --allow-emit-signal 启动时调
56
+ # emit-signal 子命令。前置条件错(与 1009 NOT_PAUSED 同族)——agent 应重启 daemon 加该
57
+ # flag(debug-build + localhost 之上第三重显式门)。emit_signal 默认仍在方法黑名单里,
58
+ # call <node> emit_signal 始终被拒。
59
+ const EMIT_SIGNAL_DISABLED: int = 1015
60
+ # 响应超出站 WebSocket 缓冲(issue #160):单条响应 JSON 超过 outbound_buffer_size
61
+ # (默认 10MB,godot_cli_control/outbound_buffer_mb 可调)时 send_text 失败。
62
+ # 容量/资源类永久错——同一响应重试必再超;agent 应改用 path 落盘(screenshot)
63
+ # 或调大 buffer。daemon 用它替换发不出去的大响应,避免 client 干等到 -1002 假超时。
64
+ const RESPONSE_TOO_LARGE: int = 1016
47
65
 
48
66
  const INVALID_PARAMS: int = -32602
49
67
  const INVALID_REQUEST: int = -32600
@@ -37,6 +37,10 @@ var _diag_api: DiagnosticsApi = null
37
37
  # - async: handler(params) -> await Dictionary,dispatcher await 后 send response
38
38
  # - async_with_id: handler(params, request_id) -> void,handler 自行通过 callback 回响
39
39
  var _methods: Dictionary = {}
40
+ # quit RPC(#156):退出动作抽成可注入 Callable,GUT 里替换成 spy 不真退。
41
+ # 默认 get_tree().quit() —— SceneTree.quit() 延迟到当前帧末执行,dispatcher 已在
42
+ # 本帧同步发出 {ok} 响应,给 ws 一次 poll flush 机会;client 端另有「断连=成功」兜底。
43
+ var _quit_action: Callable = func() -> void: get_tree().quit()
40
44
 
41
45
 
42
46
  func _ready() -> void:
@@ -62,7 +66,11 @@ func _ready() -> void:
62
66
  _wait_api = WaitApi.new()
63
67
  _wait_api.name = "WaitApi"
64
68
  add_child(_wait_api)
65
- _wait_api.setup(_low_level_api._read_property)
69
+ _wait_api.setup({
70
+ "read_property": _low_level_api._read_property,
71
+ "send_response": _on_async_response,
72
+ "send_armed": _send_armed,
73
+ })
66
74
  _scene_api = SceneApi.new()
67
75
  _scene_api.name = "SceneApi"
68
76
  add_child(_scene_api)
@@ -85,6 +93,12 @@ func _ready() -> void:
85
93
  print("GameBridge: Engine.time_scale = %s (from --cli-time-scale)" % startup_scale)
86
94
  # 构建统一方法注册表
87
95
  _register_methods()
96
+ # 录制防遮挡冻帧(#156 子问题 B):daemon 传 --game-bridge-always-on-top 时置顶窗口。
97
+ # macOS 对被遮挡窗口节流渲染 → Movie Maker 写 stale 帧;置顶根治。失焦无碍、遮挡才致命。
98
+ if _has_cli_flag("--game-bridge-always-on-top"):
99
+ var w := get_window()
100
+ if w:
101
+ w.always_on_top = true
88
102
  # 缓存 outbound buffer 大小(ProjectSettings 可覆盖默认 10MB,至少 1MB)
89
103
  var mb: int = int(
90
104
  ProjectSettings.get_setting(SETTING_OUTBOUND_BUFFER_MB, DEFAULT_OUTBOUND_BUFFER_MB)
@@ -208,17 +222,23 @@ func _register_methods() -> void:
208
222
  _methods["get_properties"] = {"callable": _low_level_api.handle_get_properties, "kind": "sync"}
209
223
  _methods["set_property"] = {"callable": _low_level_api.handle_set_property, "kind": "sync"}
210
224
  _methods["call_method"] = {"callable": _low_level_api.handle_call_method, "kind": "sync"}
225
+ # emit-signal 逃生门(#157 item4,sync):默认禁,daemon --allow-emit-signal 放开。
226
+ _methods["emit_signal"] = {"callable": _low_level_api.handle_emit_signal, "kind": "sync"}
211
227
  _methods["get_text"] = {"callable": _low_level_api.handle_get_text, "kind": "sync"}
212
228
  _methods["node_exists"] = {"callable": _low_level_api.handle_node_exists, "kind": "sync"}
213
229
  _methods["is_visible"] = {"callable": _low_level_api.handle_is_visible, "kind": "sync"}
214
230
  _methods["get_children"] = {"callable": _low_level_api.handle_get_children, "kind": "sync"}
215
231
  _methods["get_scene_tree"] = {"callable": _low_level_api.handle_get_scene_tree, "kind": "sync"}
232
+ _methods["find_nodes"] = {"callable": _low_level_api.handle_find_nodes, "kind": "sync"}
216
233
  # Wait API(异步)
217
234
  _methods["wait_for_node"] = {"callable": _wait_api.wait_for_node_async, "kind": "async"}
218
235
  _methods["wait_game_time"] = {"callable": _wait_api.wait_game_time_async, "kind": "async"}
219
236
  _methods["wait_frames"] = {"callable": _wait_api.wait_frames_async, "kind": "async"}
220
237
  _methods["wait_property"] = {"callable": _wait_api.wait_property_async, "kind": "async"}
221
- _methods["wait_signal"] = {"callable": _wait_api.wait_signal_async, "kind": "async"}
238
+ _methods["wait_signal"] = {"callable": _wait_api.wait_signal_async, "kind": "async_with_id"}
239
+ # #172 item1:client 在 trigger(on_armed)完成后发来,通知 WaitApi 此刻才开始计
240
+ # 等信号 timeout(trigger 执行时间不占预算)。sync:回 {ok} ack,client fire-and-forget。
241
+ _methods["wait_signal_start_timer"] = {"callable": _handle_wait_signal_start_timer, "kind": "sync"}
222
242
  _methods["screenshot"] = {"callable": _wrap_screenshot, "kind": "async"}
223
243
  # 输入模拟(同步)
224
244
  _methods["input_action_press"] = {
@@ -237,6 +257,11 @@ func _register_methods() -> void:
237
257
  _methods["input_combo_cancel"] = {
238
258
  "callable": _input_sim_api.handle_combo_cancel, "kind": "sync"
239
259
  }
260
+ # 坐标级鼠标事件(issue #154,同步)
261
+ _methods["click_at"] = {"callable": _input_sim_api.handle_click_at, "kind": "sync"}
262
+ _methods["mouse_move"] = {"callable": _input_sim_api.handle_mouse_move, "kind": "sync"}
263
+ # 坐标级拖拽(issue #154 P2,async 协程:按 duration/steps 插值,await 后回响应)
264
+ _methods["drag"] = {"callable": _input_sim_api.handle_drag, "kind": "async"}
240
265
  # 输入模拟(async_with_id:handler 自行通过 _on_async_response 回响)
241
266
  _methods["input_combo"] = {"callable": _input_sim_api.handle_combo, "kind": "async_with_id"}
242
267
  # Scene API(异步)
@@ -250,6 +275,9 @@ func _register_methods() -> void:
250
275
  _methods["pause"] = {"callable": _time_api.handle_pause, "kind": "sync"}
251
276
  _methods["unpause"] = {"callable": _time_api.handle_unpause, "kind": "sync"}
252
277
  _methods["step_frames"] = {"callable": _time_api.step_frames_async, "kind": "async"}
278
+ # quit(#156,sync):内部 RPC,无对应 CLI 子命令——用户语义即 daemon stop
279
+ # (契约 4 有意例外)。daemon stop 在 SIGTERM 前发此 RPC 让 Movie Maker flush 尾帧。
280
+ _methods["quit"] = {"callable": _handle_quit, "kind": "sync"}
253
281
 
254
282
 
255
283
  # screenshot wrapper:params 透传(issue #101 起带可选 "node" 裁剪参数)
@@ -257,6 +285,13 @@ func _wrap_screenshot(params: Dictionary) -> Dictionary:
257
285
  return await _low_level_api.take_screenshot_async(params)
258
286
 
259
287
 
288
+ # quit handler(#156):请求引擎优雅退出。返回 {ok} 让 dispatcher 正常发响应,
289
+ # 退出动作走 _quit_action(默认 get_tree().quit(),帧末生效)。
290
+ func _handle_quit(_params: Dictionary) -> Dictionary:
291
+ _quit_action.call()
292
+ return {"ok": true}
293
+
294
+
260
295
  func _handle_message(raw: String) -> void:
261
296
  _last_activity_ms = Time.get_ticks_msec()
262
297
  var parsed: Variant = JSON.parse_string(raw)
@@ -329,6 +364,24 @@ func _on_async_response(id: String, result: Dictionary) -> void:
329
364
  _dispatch_result(id, result)
330
365
 
331
366
 
367
+ # armed 中间帧(issue #155):arm 完成同步点。不动 _in_flight —— 它不是终态响应,
368
+ # 终帧仍走 _on_async_response。client 据此在同连接发 trigger 子命令再等终帧。
369
+ # kind:"armed" 是 client 识别中间帧的正向判据(#172 item2);保留 armed:true 字段
370
+ # 给旧 client 前向兼容(旧 client 靠「有 armed 且无 result/error」识别)。
371
+ func _send_armed(id: String) -> void:
372
+ _send_json({"id": id, "armed": true, "kind": "armed"})
373
+
374
+
375
+ # #172 item1:wait_signal_start_timer 的 sync handler。client 在 trigger 完成后发来,
376
+ # 通知 WaitApi 对应在途 wait_signal 此刻才开始计 timeout。req_id 缺失 / 未知(已超时
377
+ # 清理 / 非 arm_ack 路径)= no-op(WaitApi.notify_start_timer 内部容错)。
378
+ # 回 {ok} ack —— client fire-and-forget 发出后不等该响应(_listen 见 id 不在 pending 即丢弃)。
379
+ func _handle_wait_signal_start_timer(params: Dictionary) -> Dictionary:
380
+ var req_id: String = params.get("req_id", "") as String
381
+ _wait_api.notify_start_timer(req_id)
382
+ return {"ok": true}
383
+
384
+
332
385
  func _send_response(id: String, result: Dictionary) -> void:
333
386
  var response: Dictionary = {"id": id, "result": result}
334
387
  _send_json(response)
@@ -339,11 +392,70 @@ func _send_error(id: String, code: int, message: String) -> void:
339
392
  _send_json(response)
340
393
 
341
394
 
395
+ # 纯函数(issue #160):给定一条发送失败的出站帧 data,决定补发什么。
396
+ # 返回 {} = 不补发。抽成纯函数以便单测三条分支(递归守卫 / 空 id / 正常响应),
397
+ # 无副作用、不碰 peer。
398
+ func _oversize_fallback_for(data: Dictionary) -> Dictionary:
399
+ if data.has("error"):
400
+ # 失败的本就是 error 信封 → 不补发(递归终止根因:补发的也是 error 信封,
401
+ # 若它再失败会带 error,再次落到这里返回 {})。
402
+ return {}
403
+ var id_raw: Variant = data.get("id", "")
404
+ if not (id_raw is String):
405
+ return {}
406
+ var id: String = id_raw
407
+ if id.is_empty():
408
+ # fire-and-forget:client 不 await,补发没人收。
409
+ return {}
410
+ return {
411
+ "id": id,
412
+ "error": {
413
+ "code": CliControlErrorCodes.RESPONSE_TOO_LARGE,
414
+ "message": (
415
+ "response too large for outbound buffer; "
416
+ + "for screenshot pass a file path (daemon writes directly), "
417
+ + "or raise godot_cli_control/outbound_buffer_mb"
418
+ ),
419
+ },
420
+ }
421
+
422
+
423
+ # peer 状态门接缝(issue #160):默认判活跃 OPEN peer;GUT 子类 override 成 true 绕真 socket。
424
+ func _can_transmit() -> bool:
425
+ return _active_peer != null and _active_peer.get_ready_state() == WebSocketPeer.STATE_OPEN
426
+
427
+
428
+ # 出站发送接缝(issue #160):默认真发;GUT 子类 override 成「返回预置 err + 捕获」测失败分支。
429
+ func _transmit(text: String) -> Error:
430
+ return _active_peer.send_text(text)
431
+
432
+
342
433
  func _send_json(data: Dictionary) -> void:
343
- if _active_peer == null or _active_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
434
+ if not _can_transmit():
344
435
  return
345
436
  var json_str: String = JSON.stringify(data)
346
- _active_peer.send_text(json_str)
437
+ var err: Error = _transmit(json_str)
438
+ if err == OK:
439
+ return
440
+ # 发送失败——最常见是单帧超 outbound_buffer;peer 在 _can_transmit 后、_transmit 前
441
+ # 断线(TOCTOU 窗口)也走这里,故 err=%d 自报码、文案不写死「超 buffer」。stderr 留痕
442
+ # (不污染 stdout 单行 JSON 契约),并对带 id 的响应补发小错误信封,避免 client 干等到 -1002。
443
+ var byte_len: int = json_str.to_utf8_buffer().size()
444
+ var detail: String = (
445
+ "[Godot CLI Control] _send_json failed (err=%d): payload %d bytes, outbound buffer %d bytes"
446
+ % [err, byte_len, _outbound_buffer_size]
447
+ )
448
+ push_error(detail)
449
+ printerr(detail)
450
+ var fallback: Dictionary = _oversize_fallback_for(data)
451
+ if fallback.is_empty():
452
+ return
453
+ # 补发走 _transmit 直发(不回 _send_json)→ 无递归路径;信封 ~百字节,buffer 下限 1MB 必能装下。
454
+ var fb_err: Error = _transmit(JSON.stringify(fallback))
455
+ if fb_err != OK:
456
+ printerr(
457
+ "[Godot CLI Control] oversize fallback envelope also failed (err=%d); dropping" % fb_err
458
+ )
347
459
 
348
460
 
349
461
  func _should_activate() -> bool:
@@ -20,6 +20,14 @@ var _combo_completed_steps: int = 0
20
20
  var _combo_request_id: String = ""
21
21
  # 用于 GameBridge 发送响应
22
22
  var _send_response_callback: Callable = Callable()
23
+ # 坐标级鼠标事件(issue #154):上一次注入的鼠标位置,用于 InputEventMouseMotion
24
+ # 的 relative 计算。viewport 物理像素系,初始 (0,0)。
25
+ var _last_mouse_pos: Vector2 = Vector2.ZERO
26
+ # drag 互斥(issue #154 P2):协程插值期间为 true。同时兼作取消信号——
27
+ # release_all 把它置 false,挂起的 drag 协程 resume 后据此早退(不再注入事件)。
28
+ var _mouse_drag_active: bool = false
29
+ # 当前 drag 持有的鼠标键,供 release_all 中断时补对应的 mouse-up。
30
+ var _drag_button: int = MOUSE_BUTTON_LEFT
23
31
 
24
32
 
25
33
  func setup(send_response: Callable) -> void:
@@ -142,6 +150,12 @@ func release_all() -> void:
142
150
  for action: String in _held_actions:
143
151
  _do_release(action)
144
152
  _held_actions.clear()
153
+ # 中断在途 drag(issue #154 P2):补一个 mouse-up 避免被拖控件卡在「拖拽中」,
154
+ # 并复位 mutex —— 挂起的 drag 协程 resume 后看到 false 即早退、不再注入事件。
155
+ # 先复位再 emit:顺序无关(_emit 不读 flag),但语义上先解锁。
156
+ if _mouse_drag_active:
157
+ _mouse_drag_active = false
158
+ _emit_mouse_button(_last_mouse_pos, _drag_button, false, false)
145
159
 
146
160
 
147
161
  # ── Combo ──
@@ -293,5 +307,145 @@ func _do_release(action: String) -> void:
293
307
  Input.parse_input_event(ev)
294
308
 
295
309
 
310
+ # ── 坐标级鼠标事件注入(issue #154,P1: click-at / mouse-move)──
311
+ #
312
+ # 经 viewport 注入真实事件管线(详见 _emit_mouse_button 上方的路径取舍说明),
313
+ # 让依赖光标位置的 _gui_input 控件 / 全局 _input / 物理 picking 能命中——这是
314
+ # click(定点 emit、需预知目标节点)补不上的坐标级能力。坐标统一用 viewport 物理
315
+ # 像素系;--node 糖衣复用 RenderApi.compute_node_screen_rect 取节点中心点
316
+ # (已含 viewport.get_final_transform(),与 screenshot 取图侧同系,#137)。
317
+
318
+ func handle_click_at(params: Dictionary) -> Dictionary:
319
+ var point: Variant = _resolve_point(params, "node", "x", "y")
320
+ if point is Dictionary:
321
+ return point as Dictionary
322
+ var pos: Vector2 = point as Vector2
323
+ var button: int = int(params.get("button", MOUSE_BUTTON_LEFT))
324
+ var double: bool = bool(params.get("double", false))
325
+ _emit_mouse_button(pos, button, true, double)
326
+ _emit_mouse_button(pos, button, false, double)
327
+ return {"success": true, "x": pos.x, "y": pos.y, "button": button, "double": double}
328
+
329
+
330
+ func handle_mouse_move(params: Dictionary) -> Dictionary:
331
+ var point: Variant = _resolve_point(params, "node", "x", "y")
332
+ if point is Dictionary:
333
+ return point as Dictionary
334
+ var pos: Vector2 = point as Vector2
335
+ var rel: Vector2 = pos - _last_mouse_pos
336
+ _emit_mouse_motion(pos, 0)
337
+ return {"success": true, "x": pos.x, "y": pos.y, "relative": [rel.x, rel.y]}
338
+
339
+
340
+ ## 坐标级拖拽(issue #154 P2,kind=async 协程):start 处按下鼠标键 → 按
341
+ ## duration/steps 分摊插值出 motion(每步 await create_timer,受 Engine.time_scale,
342
+ ## 与 combo 同 game-time 语义、暂停时仍随 GameBridge PROCESS_MODE_ALWAYS 推进)→
343
+ ## end 处松开。dispatcher await 整个协程再回响应;插值期间 _in_flight>0,
344
+ ## idle-timeout 不会半路打断。
345
+ ##
346
+ ## 单拖拽互斥(_mouse_drag_active):已有 drag 在途再调 → 1014。坐标解析与参数
347
+ ## 校验都在第一个 await 之前完成,失败直接返回、不占用 mutex。中途被 release_all
348
+ ## 取消时(mutex 被置 false),协程 resume 后据此早退。
349
+ func handle_drag(params: Dictionary) -> Dictionary:
350
+ if _mouse_drag_active:
351
+ return _err(CliControlErrorCodes.DRAG_IN_PROGRESS, "drag in progress")
352
+ var from_point: Variant = _resolve_point(params, "from_node", "x1", "y1")
353
+ if from_point is Dictionary:
354
+ return from_point as Dictionary
355
+ var to_point: Variant = _resolve_point(params, "to_node", "x2", "y2")
356
+ if to_point is Dictionary:
357
+ return to_point as Dictionary
358
+ var start: Vector2 = from_point as Vector2
359
+ var target: Vector2 = to_point as Vector2
360
+ var button: int = int(params.get("button", MOUSE_BUTTON_LEFT))
361
+ var duration: float = float(params.get("duration", 0.3))
362
+ var steps: int = int(params.get("steps", 10))
363
+ if steps < 1:
364
+ return _err(CliControlErrorCodes.INVALID_PARAMS, "steps must be >= 1 (got %d)" % steps)
365
+ if duration < 0.0:
366
+ return _err(CliControlErrorCodes.INVALID_PARAMS, "duration must be >= 0 (got %s)" % duration)
367
+ _mouse_drag_active = true
368
+ _drag_button = button
369
+ # 起点按下:_emit_mouse_button 同步把 _last_mouse_pos 设为 start,
370
+ # 让首个插值 motion 的 relative 是「与起点的差值」。
371
+ _emit_mouse_button(start, button, true, false)
372
+ var mask: int = 1 << (button - 1)
373
+ var step_dt: float = duration / float(steps)
374
+ for i: int in range(1, steps + 1):
375
+ await get_tree().create_timer(step_dt).timeout
376
+ # 被 release_all 取消:它已复位 mutex 并补过 mouse-up,这里直接早退不再注入。
377
+ if not _mouse_drag_active:
378
+ return {"success": true, "cancelled": true}
379
+ var pos: Vector2 = start.lerp(target, float(i) / float(steps))
380
+ _emit_mouse_motion(pos, mask)
381
+ _emit_mouse_button(target, button, false, false)
382
+ _mouse_drag_active = false
383
+ return {
384
+ "success": true,
385
+ "from": [start.x, start.y],
386
+ "to": [target.x, target.y],
387
+ "button": button,
388
+ "steps": steps,
389
+ "duration": duration,
390
+ }
391
+
392
+
393
+ ## 解析坐标:优先 ``node`` key(取节点中心点),否则字面 ``x``/``y``。
394
+ ## 返回 Vector2(物理像素)或 error Dictionary(1001 找不到 / 1010 非 CanvasItem /
395
+ ## -32602 既无 node 又无坐标)。compute_node_screen_rect 失败时本身就返回
396
+ ## {"error": {...}}(1010),直接透传。
397
+ func _resolve_point(params: Dictionary, node_key: String, x_key: String, y_key: String) -> Variant:
398
+ if params.has(node_key):
399
+ var path: String = params[node_key] as String
400
+ var node: Node = get_tree().root.get_node_or_null(path)
401
+ if node == null:
402
+ return _err(CliControlErrorCodes.NODE_NOT_FOUND, "Node not found: %s" % path)
403
+ var rect_or_err: Variant = RenderApi.compute_node_screen_rect(node)
404
+ if rect_or_err is Dictionary:
405
+ return rect_or_err
406
+ return (rect_or_err as Rect2).get_center()
407
+ if params.has(x_key) and params.has(y_key):
408
+ return Vector2(params[x_key] as float, params[y_key] as float)
409
+ return _err(
410
+ CliControlErrorCodes.INVALID_PARAMS,
411
+ "requires literal x,y or a node path (got neither)",
412
+ )
413
+
414
+
415
+ # 鼠标事件统一走 get_viewport().push_input(),而非 action 事件用的
416
+ # Input.parse_input_event。原因(与 action 的取舍不同,刻意区分):
417
+ # 1. relative 自控:parse_input_event 会用 Input 单例内部追踪的 mouse_pos
418
+ # 重算 InputEventMouseMotion.relative,覆盖我们设的差值(headless 下 mouse_pos
419
+ # 恒 (0,0),relative 直接错)。push_input 保留 ev.relative —— 这是 P2 drag
420
+ # 插值序列正确性的刚需。
421
+ # 2. 同路径保序:button 与 motion 必须走同一管道,否则 Input 单例的 buffer 与
422
+ # viewport 直分发的到达顺序可能错乱(drag 的 down→motion→up 会乱序)。
423
+ # 3. 与现有 click handler 的直接 emit 同精神(都不经 Input 单例)。
424
+ # 已知限制:不更新 Input 单例的全局鼠标轮询(get_global_mouse_position /
425
+ # is_mouse_button_pressed)。事件的 position / relative / button_mask 对
426
+ # _input / _gui_input / 物理 picking 有效;读鼠标态请从事件参数读,勿用轮询。
427
+ func _emit_mouse_button(pos: Vector2, button: int, pressed: bool, double: bool = false) -> void:
428
+ var ev: InputEventMouseButton = InputEventMouseButton.new()
429
+ ev.button_index = button
430
+ ev.pressed = pressed
431
+ ev.double_click = double
432
+ ev.position = pos
433
+ ev.global_position = pos
434
+ # 按下时带本键的 button_mask(松开归零),与真实事件一致。mask = 1 << (idx-1)。
435
+ ev.button_mask = (1 << (button - 1)) if pressed else 0
436
+ get_viewport().push_input(ev)
437
+ _last_mouse_pos = pos
438
+
439
+
440
+ func _emit_mouse_motion(pos: Vector2, button_mask: int = 0) -> void:
441
+ var ev: InputEventMouseMotion = InputEventMouseMotion.new()
442
+ ev.position = pos
443
+ ev.global_position = pos
444
+ ev.relative = pos - _last_mouse_pos
445
+ ev.button_mask = button_mask
446
+ get_viewport().push_input(ev)
447
+ _last_mouse_pos = pos
448
+
449
+
296
450
  func _err(code: int, message: String) -> Dictionary:
297
451
  return {"error": {"code": code, "message": message}}