godot-cli-control 0.4.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.4.0 → godot_cli_control-0.4.1}/PKG-INFO +1 -1
  2. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/CHANGELOG.md +5 -0
  3. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/README.md +2 -2
  4. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/low_level_api.gd +144 -46
  5. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +152 -0
  6. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/_version.py +2 -2
  7. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/templates/skill/SKILL.md +27 -1
  8. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/.gitignore +0 -0
  9. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/LICENSE +0 -0
  10. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/LICENSE +0 -0
  11. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  12. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  13. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/capture_logger.gd +0 -0
  14. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/diagnostics_api.gd +0 -0
  15. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  16. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  17. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  18. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  19. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  20. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  21. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/render_api.gd +0 -0
  22. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/scene_api.gd +0 -0
  23. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/time_api.gd +0 -0
  24. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/variant_codec.gd +0 -0
  25. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/bridge/wait_api.gd +0 -0
  26. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/plugin.cfg +0 -0
  27. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/plugin.gd +0 -0
  28. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/plugin.gd.uid +0 -0
  29. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_diagnostics_api.gd +0 -0
  30. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  31. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  32. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_render_api.gd +0 -0
  33. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_scene_api.gd +0 -0
  34. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_time_api.gd +0 -0
  35. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_variant_codec.gd +0 -0
  36. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/gut/test_wait_api.gd +0 -0
  37. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/run_gut.py +0 -0
  38. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  39. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  40. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  41. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/pyproject.toml +0 -0
  42. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/README.md +0 -0
  43. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/__init__.py +0 -0
  44. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/__main__.py +0 -0
  45. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/_duration.py +0 -0
  46. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/bridge.py +0 -0
  47. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/cli.py +0 -0
  48. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/client.py +0 -0
  49. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/daemon.py +0 -0
  50. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/init_cmd.py +0 -0
  51. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/pytest_plugin.py +0 -0
  52. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/registry.py +0 -0
  53. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/runner.py +0 -0
  54. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/skills_install.py +0 -0
  55. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/templates/__init__.py +0 -0
  56. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  57. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/__init__.py +0 -0
  58. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/conftest.py +0 -0
  59. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_bridge.py +0 -0
  60. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_broadcast.py +0 -0
  61. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_cli.py +0 -0
  62. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_cli_helpers.py +0 -0
  63. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_client.py +0 -0
  64. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_daemon.py +0 -0
  65. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_duration.py +0 -0
  66. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_client_direct.py +0 -0
  67. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_diag.py +0 -0
  68. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_example.py +0 -0
  69. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_input.py +0 -0
  70. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_multi_instance.py +0 -0
  71. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_record.py +0 -0
  72. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_render.py +0 -0
  73. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_scene.py +0 -0
  74. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_screenshot_gui.py +0 -0
  75. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_e2e_time.py +0 -0
  76. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_init.py +0 -0
  77. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_issue_fixes.py +0 -0
  78. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_pytest_plugin.py +0 -0
  79. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_registry.py +0 -0
  80. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_runner.py +0 -0
  81. {godot_cli_control-0.4.0 → godot_cli_control-0.4.1}/python/tests/test_skills_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.4.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,11 @@
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
+
9
14
  ## [0.4.0] - 2026-06-12
10
15
 
11
16
  ### Added
@@ -89,7 +89,7 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
89
89
  | `get_property(path, property)` | `await client.get_property("/root/Player", "position")` — returns bare value; CLI `get` returns `{"value": ..., "type"?: ...}` shape |
90
90
  | `get_properties(path, properties)` | `await client.get_properties("/root/Player", ["position", "health"])` — multi-property atomic read; returns `{prop: bare_value, ...}` dict |
91
91
  | `set_property(path, property, value)` | `await client.set_property("/root/Player", "visible", False)` |
92
- | `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
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...]` |
94
94
  | `get_text(path)` | `await client.get_text("/root/UI/Label")` |
95
95
  | `node_exists(path)` | `await client.node_exists("/root/MyScene/Button")` |
@@ -157,7 +157,7 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
157
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. |
158
158
  | `-32600` | server | Malformed JSON-RPC request |
159
159
  | `-32601` | server | Unknown method name |
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, 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`) |
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`) |
161
161
  | `-1001` | client | Connection failure (daemon not running, port wrong, proxy hijacking localhost) |
162
162
  | `-1002` | client | Timeout waiting for response |
163
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). |
@@ -100,6 +100,22 @@ const _ARRAY_PASSTHROUGH_SAFE_TYPES: Array[int] = [
100
100
  TYPE_PACKED_VECTOR4_ARRAY,
101
101
  ]
102
102
 
103
+ # #167:call 实参传 JSON Array 时,「callv 能正常透传、不会 Cannot-convert 静默失败」的
104
+ # 声明参数类型白名单。命中 = 直接把原 Array 喂 callv;不命中且非可 coerce 的复合 Variant
105
+ # = fail-loud(见 _coerce_call_arg)。入选:untyped(Variant) 形参、Array 形参、Packed*Array
106
+ # (Godot 允许 Array→Packed* 的 call-arg 转换)。
107
+ # **刻意不含** Dictionary / 标量 / Object —— JSON Array → 这些类型 callv 会 "Cannot convert
108
+ # argument" 喷错并返回 null(旧版假 ok:true 的根因),必须拦。注意与 _ARRAY_PASSTHROUGH_SAFE_TYPES
109
+ # 语义不同:那张表是 property set 侧「Object.set 不 silent-corrupt」,这张是 call 侧
110
+ # 「callv 不静默失败」——两套失败模式不同,故不能共用同一张表。
111
+ const _CALL_ARG_ARRAY_OK_TYPES: Array[int] = [
112
+ TYPE_NIL, TYPE_ARRAY,
113
+ TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,
114
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,
115
+ TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY,
116
+ TYPE_PACKED_VECTOR4_ARRAY,
117
+ ]
118
+
103
119
 
104
120
  func _ready() -> void:
105
121
  _property_blacklist = _merge_extra(_property_blacklist, SETTING_PROPERTY_BLACKLIST_EXTRA)
@@ -285,14 +301,39 @@ func handle_set_property(params: Dictionary) -> Dictionary:
285
301
  return {"success": true}
286
302
 
287
303
 
288
- ## JSON Array 按声明类型转成 Variant:Vector2/2i/3/3i/4/4i / Rect2/2i /
289
- ## Color / Plane / Quaternion / AABB / Basis / Transform2D/3D / Projection。
304
+ ## 按属性声明类型把 JSON Array coerce 成对应 Variant(property set 侧入口)。
290
305
  ## 节点没声明该属性:返 {} 表示"沿用原 value"。
291
- ## 声明类型在 match 之外且 _ARRAY_PASSTHROUGH_SAFE_TYPES:返 {"error": ...} fail-loud,
292
- ## 防御未来 Godot 加新 compound Variant silent-corrupt 回归(详见 fallback 注释)。
293
- ## 声明类型在 _ARRAY_PASSTHROUGH_SAFE_TYPES(基本类型 / 集合 / Packed*):返 {} 沿用原 value。
294
- ## 转换失败(长度不对 / 元素非数字):返 {"error": ...} 让调用方 fail-loud。
295
- ## 转换成功:返 {"value": <coerced>}。
306
+ ## 声明类型是复合 Variant:委托 _coerce_compound_array(成功 {"value":...} / 长度·元素错 {"error":...})。
307
+ ## 声明类型非复合:在 _ARRAY_PASSTHROUGH_SAFE_TYPES {} 沿用原 value;否则 fail-loud
308
+ ## (防御未来 Godot 加新 compound Variant silent-corrupt 回归,详见下方 fallback 注释)。
309
+ func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) -> Dictionary:
310
+ var declared_type: int = -1
311
+ for prop_info: Dictionary in node.get_property_list():
312
+ if prop_info["name"] == property:
313
+ declared_type = int(prop_info["type"])
314
+ break
315
+ if declared_type == -1:
316
+ return {} # 动态 / 未声明属性,沿用原 value
317
+ var coerced: Dictionary = _coerce_compound_array(declared_type, arr, property)
318
+ if not coerced.is_empty():
319
+ return coerced # 复合类型命中:{"value": ...} 或 {"error": ...}
320
+ # 防御性 fallback:声明类型不是复合 Variant 也不在已知 passthrough-safe 名单里时,主动
321
+ # fail-loud。目的是防止未来 Godot 加新 compound Variant(且 Object.set 同样 silent-corrupt-
322
+ # on-Array)时重蹈 #52。passthrough-safe = 基本类型 / Object / 集合 / Packed*Array —— 它们
323
+ # 要么 Godot Object.set 自己拒收 Array(写入失败但不 silent),要么本就接受 Array 输入。
324
+ if declared_type in _ARRAY_PASSTHROUGH_SAFE_TYPES:
325
+ return {}
326
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
327
+ "value type mismatch for '%s': Array coercion not implemented for declared type %d. If this is a known-safe passthrough, add it to _ARRAY_PASSTHROUGH_SAFE_TYPES; otherwise add a coerce branch."
328
+ % [property, declared_type])
329
+
330
+
331
+ ## 把 JSON Array 按「复合 Variant 声明类型」转成对应 Variant:Vector2/2i/3/3i/4/4i /
332
+ ## Rect2/2i / Color / Plane / Quaternion / AABB / Basis / Transform2D/3D / Projection。
333
+ ## 命中复合类型 → {"value": <coerced>}(成功)或 {"error": ...}(长度/元素不对,fail-loud);
334
+ ## 声明类型不是复合 Variant → 返 {},由调用方决定 passthrough 还是 fail-loud
335
+ ## (property set 侧 _ARRAY_PASSTHROUGH_SAFE_TYPES;call 实参侧 _CALL_ARG_ARRAY_OK_TYPES)。
336
+ ## `label` 仅用于错误信息(property set 传属性名,call 实参传 "arg N of <method>")。
296
337
  ## *i 变体(Vector2i / Vector3i / Vector4i / Rect2i)允许 float 输入并截断到 int,
297
338
  ## 与 GDScript `Vector2i(1.7, 2.3) → (1, 2)` 构造器行为一致。
298
339
  ##
@@ -303,63 +344,56 @@ func handle_set_property(params: Dictionary) -> Dictionary:
303
344
  ## - Transform3D : [basis 9 axis-vector, origin.xyz] (12 floats)
304
345
  ## - Projection : [xaxis.xyzw, yaxis.xyzw, zaxis.xyzw, waxis.xyzw] (16 floats)
305
346
  ## Quaternion / Plane 的 normal 不会自动归一化 —— 调用方传非单位向量后果自负。
306
- func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) -> Dictionary:
307
- var declared_type: int = -1
308
- for prop_info: Dictionary in node.get_property_list():
309
- if prop_info["name"] == property:
310
- declared_type = int(prop_info["type"])
311
- break
312
- if declared_type == -1:
313
- return {} # 动态 / 未声明属性,沿用原 value
347
+ func _coerce_compound_array(declared_type: int, arr: Array, label: String) -> Dictionary:
314
348
  match declared_type:
315
349
  TYPE_VECTOR2:
316
- return _coerce_numeric_array(arr, [2], "Vector2", property, func(v: Array) -> Variant:
350
+ return _coerce_numeric_array(arr, [2], "Vector2", label, func(v: Array) -> Variant:
317
351
  return Vector2(v[0], v[1]))
318
352
  TYPE_VECTOR2I:
319
- return _coerce_numeric_array(arr, [2], "Vector2i", property, func(v: Array) -> Variant:
353
+ return _coerce_numeric_array(arr, [2], "Vector2i", label, func(v: Array) -> Variant:
320
354
  return Vector2i(int(v[0]), int(v[1])))
321
355
  TYPE_VECTOR3:
322
- return _coerce_numeric_array(arr, [3], "Vector3", property, func(v: Array) -> Variant:
356
+ return _coerce_numeric_array(arr, [3], "Vector3", label, func(v: Array) -> Variant:
323
357
  return Vector3(v[0], v[1], v[2]))
324
358
  TYPE_VECTOR3I:
325
- return _coerce_numeric_array(arr, [3], "Vector3i", property, func(v: Array) -> Variant:
359
+ return _coerce_numeric_array(arr, [3], "Vector3i", label, func(v: Array) -> Variant:
326
360
  return Vector3i(int(v[0]), int(v[1]), int(v[2])))
327
361
  TYPE_VECTOR4:
328
- return _coerce_numeric_array(arr, [4], "Vector4", property, func(v: Array) -> Variant:
362
+ return _coerce_numeric_array(arr, [4], "Vector4", label, func(v: Array) -> Variant:
329
363
  return Vector4(v[0], v[1], v[2], v[3]))
330
364
  TYPE_VECTOR4I:
331
- return _coerce_numeric_array(arr, [4], "Vector4i", property, func(v: Array) -> Variant:
365
+ return _coerce_numeric_array(arr, [4], "Vector4i", label, func(v: Array) -> Variant:
332
366
  return Vector4i(int(v[0]), int(v[1]), int(v[2]), int(v[3])))
333
367
  TYPE_RECT2:
334
- return _coerce_numeric_array(arr, [4], "Rect2", property, func(v: Array) -> Variant:
368
+ return _coerce_numeric_array(arr, [4], "Rect2", label, func(v: Array) -> Variant:
335
369
  return Rect2(v[0], v[1], v[2], v[3]))
336
370
  TYPE_RECT2I:
337
- return _coerce_numeric_array(arr, [4], "Rect2i", property, func(v: Array) -> Variant:
371
+ return _coerce_numeric_array(arr, [4], "Rect2i", label, func(v: Array) -> Variant:
338
372
  return Rect2i(int(v[0]), int(v[1]), int(v[2]), int(v[3])))
339
373
  TYPE_COLOR:
340
374
  # Color 接受 RGB(3)或 RGBA(4)。3-element 时 a 默认 1。
341
- return _coerce_numeric_array(arr, [3, 4], "Color", property, func(v: Array) -> Variant:
375
+ return _coerce_numeric_array(arr, [3, 4], "Color", label, func(v: Array) -> Variant:
342
376
  if v.size() == 3:
343
377
  return Color(v[0], v[1], v[2])
344
378
  return Color(v[0], v[1], v[2], v[3]))
345
379
  TYPE_PLANE:
346
380
  # Plane(normal_x, normal_y, normal_z, d) —— 平面方程系数。
347
381
  # 注意:normal 不会自动归一化;非单位 normal 会让距离 / 投影计算失真。
348
- return _coerce_numeric_array(arr, [4], "Plane", property, func(v: Array) -> Variant:
382
+ return _coerce_numeric_array(arr, [4], "Plane", label, func(v: Array) -> Variant:
349
383
  return Plane(v[0], v[1], v[2], v[3]))
350
384
  TYPE_QUATERNION:
351
385
  # Quaternion(x, y, z, w) —— 注意 w 在末位,与 Godot ctor 一致。
352
386
  # 注意:不会自动归一化;非单位四元数会让旋转 / slerp 失真。
353
- return _coerce_numeric_array(arr, [4], "Quaternion", property, func(v: Array) -> Variant:
387
+ return _coerce_numeric_array(arr, [4], "Quaternion", label, func(v: Array) -> Variant:
354
388
  return Quaternion(v[0], v[1], v[2], v[3]))
355
389
  TYPE_AABB:
356
390
  # AABB(position, size) —— 6 floats: [pos.xyz, size.xyz]
357
- return _coerce_numeric_array(arr, [6], "AABB", property, func(v: Array) -> Variant:
391
+ return _coerce_numeric_array(arr, [6], "AABB", label, func(v: Array) -> Variant:
358
392
  return AABB(Vector3(v[0], v[1], v[2]), Vector3(v[3], v[4], v[5])))
359
393
  TYPE_BASIS:
360
394
  # Basis(x_axis, y_axis, z_axis) —— 9 floats axis-vector 顺序:
361
395
  # v[0..2]=x_axis、v[3..5]=y_axis、v[6..8]=z_axis(每 3 个 = 一个 Basis 轴)。
362
- return _coerce_numeric_array(arr, [9], "Basis", property, func(v: Array) -> Variant:
396
+ return _coerce_numeric_array(arr, [9], "Basis", label, func(v: Array) -> Variant:
363
397
  return Basis(
364
398
  Vector3(v[0], v[1], v[2]),
365
399
  Vector3(v[3], v[4], v[5]),
@@ -367,14 +401,14 @@ func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) ->
367
401
  TYPE_TRANSFORM2D:
368
402
  # Transform2D(x_axis, y_axis, origin) —— 6 floats axis-vector 顺序:
369
403
  # v[0..1]=x_axis、v[2..3]=y_axis、v[4..5]=origin。
370
- return _coerce_numeric_array(arr, [6], "Transform2D", property, func(v: Array) -> Variant:
404
+ return _coerce_numeric_array(arr, [6], "Transform2D", label, func(v: Array) -> Variant:
371
405
  return Transform2D(
372
406
  Vector2(v[0], v[1]),
373
407
  Vector2(v[2], v[3]),
374
408
  Vector2(v[4], v[5])))
375
409
  TYPE_TRANSFORM3D:
376
410
  # Transform3D(basis, origin) —— 12 floats: [basis 9 axis-vector 顺序, origin 3]
377
- return _coerce_numeric_array(arr, [12], "Transform3D", property, func(v: Array) -> Variant:
411
+ return _coerce_numeric_array(arr, [12], "Transform3D", label, func(v: Array) -> Variant:
378
412
  return Transform3D(
379
413
  Basis(
380
414
  Vector3(v[0], v[1], v[2]),
@@ -383,35 +417,28 @@ func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) ->
383
417
  Vector3(v[9], v[10], v[11])))
384
418
  TYPE_PROJECTION:
385
419
  # Projection(x, y, z, w) —— 16 floats axis-vector 顺序:每 4 个 = 一个 Vector4 轴。
386
- return _coerce_numeric_array(arr, [16], "Projection", property, func(v: Array) -> Variant:
420
+ return _coerce_numeric_array(arr, [16], "Projection", label, func(v: Array) -> Variant:
387
421
  return Projection(
388
422
  Vector4(v[0], v[1], v[2], v[3]),
389
423
  Vector4(v[4], v[5], v[6], v[7]),
390
424
  Vector4(v[8], v[9], v[10], v[11]),
391
425
  Vector4(v[12], v[13], v[14], v[15])))
392
- # 防御性 fallback:声明类型不是上面的"复合 Variant"也不在已知 passthrough-safe
393
- # 名单里时,主动 fail-loud。目的是防止未来 Godot 加新 compound Variant(且
394
- # Object.set 同样 silent-corrupt-on-Array)时重蹈 #52。
395
- # passthrough-safe = 基本类型 / Object / 集合 / Packed*Array —— 它们要么 Godot
396
- # Object.set 自己拒收 Array(写入失败但不 silent),要么本就接受 Array 输入。
397
- if declared_type in _ARRAY_PASSTHROUGH_SAFE_TYPES:
398
- return {}
399
- return _err(CliControlErrorCodes.INVALID_PARAMS,
400
- "value type mismatch for '%s': Array coercion not implemented for declared type %d. If this is a known-safe passthrough, add it to _ARRAY_PASSTHROUGH_SAFE_TYPES; otherwise add a coerce branch."
401
- % [property, declared_type])
426
+ # 声明类型不是上面任一复合 Variant → 返 {},由调用方决定 passthrough / fail-loud
427
+ # (property set 侧走 _ARRAY_PASSTHROUGH_SAFE_TYPES;call 实参侧走 _CALL_ARG_ARRAY_OK_TYPES)。
428
+ return {}
402
429
 
403
430
 
404
431
  ## 校验 Array 长度 / 元素全为数字,OK 则调 ctor 构造目标 Variant。
405
432
  ## `expected_lens` 是允许长度列表(Color 接 [3, 4],其他类型固定一个长度)。
406
- func _coerce_numeric_array(arr: Array, expected_lens: Array[int], type_name: String, property: String, ctor: Callable) -> Dictionary:
433
+ func _coerce_numeric_array(arr: Array, expected_lens: Array[int], type_name: String, label: String, ctor: Callable) -> Dictionary:
407
434
  if not arr.size() in expected_lens:
408
435
  return _err(CliControlErrorCodes.INVALID_PARAMS,
409
436
  "value type mismatch for '%s': expected %s as numeric array of length %s, got length %d"
410
- % [property, type_name, _format_length_list(expected_lens), arr.size()])
437
+ % [label, type_name, _format_length_list(expected_lens), arr.size()])
411
438
  if not _is_all_numeric(arr):
412
439
  return _err(CliControlErrorCodes.INVALID_PARAMS,
413
440
  "value type mismatch for '%s': expected %s as numeric array, got non-numeric element"
414
- % [property, type_name])
441
+ % [label, type_name])
415
442
  return {"value": ctor.call(arr)}
416
443
 
417
444
 
@@ -441,11 +468,82 @@ func handle_call_method(params: Dictionary) -> Dictionary:
441
468
  if not node.has_method(method):
442
469
  return _err(CliControlErrorCodes.METHOD_NOT_FOUND, "Method not found: %s" % method)
443
470
  var args: Array = params.get("args", []) as Array
444
- # 注意:GDScript 没有 try-catch,callv 参数不匹配会产生引擎错误而非可捕获异常
445
- var result: Variant = node.callv(method, args)
471
+ # #167:GDScript try-catch,callv 参数不匹配只会喷引擎错误("Cannot convert
472
+ # argument ...")并返回 null,RPC 仍假 ok:true / result:null —— 调用方以为方法生效
473
+ # 实则没跑(#164 的 live WANDER 演示就栽在这)。连 daemon 前先按方法声明签名
474
+ # (get_method_list)做 arg 数量校验 + Array→复合 Variant coerce + 类型守卫,对不上
475
+ # 即 fail-loud,绝不放假阳性过去。
476
+ var prepared: Dictionary = _prepare_call_args(node, method, args)
477
+ if prepared.has("error"):
478
+ return prepared
479
+ var result: Variant = node.callv(method, prepared["args"] as Array)
446
480
  return {"result": result}
447
481
 
448
482
 
483
+ ## 按方法声明签名校验 + coerce 实参(#167)。返回 {"args": <coerced Array>}(可直接喂 callv)
484
+ ## 或 {"error": ...}(fail-loud)。拿不到签名(has_method 真但 get_method_list 无此条目——
485
+ ## 极少数引擎内建)→ 原样透传保旧行为(无签名可校验,不强行拦)。
486
+ func _prepare_call_args(node: Node, method: String, args: Array) -> Dictionary:
487
+ var method_info: Dictionary = _find_method_info(node, method)
488
+ if method_info.is_empty():
489
+ return {"args": args}
490
+ var declared_args: Array = method_info.get("args", []) as Array
491
+ var default_args: Array = method_info.get("default_args", []) as Array
492
+ var is_vararg: bool = (int(method_info.get("flags", 0)) & METHOD_FLAG_VARARG) != 0
493
+ # default_args 是尾部可选参数的默认值 → 必填数 = 声明数 − 默认值数。
494
+ var required: int = declared_args.size() - default_args.size()
495
+ if args.size() < required:
496
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
497
+ "argument count mismatch for '%s': expected at least %d, got %d"
498
+ % [method, required, args.size()])
499
+ # vararg 方法(rpc / 内建变参)尾部可接任意多实参 → 只校验下界,不卡上界。
500
+ if not is_vararg and args.size() > declared_args.size():
501
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
502
+ "argument count mismatch for '%s': expected at most %d, got %d"
503
+ % [method, declared_args.size(), args.size()])
504
+ var coerced_args: Array = []
505
+ for i in range(args.size()):
506
+ var arg: Variant = args[i]
507
+ # 超出声明参数个数的尾巴是 vararg 透传段,没有声明类型,原样喂 callv。
508
+ if i >= declared_args.size():
509
+ coerced_args.append(arg)
510
+ continue
511
+ var declared_type: int = int((declared_args[i] as Dictionary).get("type", TYPE_NIL))
512
+ var coerced: Dictionary = _coerce_call_arg(declared_type, arg, "arg %d of %s" % [i, method])
513
+ if coerced.has("error"):
514
+ return coerced
515
+ if coerced.has("value"):
516
+ coerced_args.append(coerced["value"])
517
+ else:
518
+ coerced_args.append(arg)
519
+ return {"args": coerced_args}
520
+
521
+
522
+ func _find_method_info(node: Node, method: String) -> Dictionary:
523
+ for mi: Dictionary in node.get_method_list():
524
+ if str(mi.get("name", "")) == method:
525
+ return mi
526
+ return {}
527
+
528
+
529
+ ## 单个 call 实参的 Array→复合 Variant coerce + 类型守卫(#167)。
530
+ ## 非 Array 实参:返 {} 透传(callv 自行处理标量 / int↔float 宽化)。
531
+ ## Array 实参命中复合声明类型:返复合 coerce 结果({"value":...} 或长度·元素错的 {"error":...})。
532
+ ## Array 实参 + 声明类型接受 Array(untyped / Array / Packed*,见 _CALL_ARG_ARRAY_OK_TYPES):返 {} 透传。
533
+ ## Array 实参 + 标量 / Object / Dictionary 等:callv 必 "Cannot convert" 静默失败 → fail-loud。
534
+ func _coerce_call_arg(declared_type: int, arg: Variant, label: String) -> Dictionary:
535
+ if not (arg is Array):
536
+ return {}
537
+ var coerced: Dictionary = _coerce_compound_array(declared_type, arg, label)
538
+ if not coerced.is_empty():
539
+ return coerced
540
+ if declared_type in _CALL_ARG_ARRAY_OK_TYPES:
541
+ return {}
542
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
543
+ "value type mismatch for '%s': cannot convert JSON Array to declared parameter type %d (a non-Array value is required)"
544
+ % [label, declared_type])
545
+
546
+
449
547
  ## emit-signal 逃生门(#157 item4):默认禁(1015),daemon 带 --allow-emit-signal 才放行。
450
548
  ## 门控最先短路(功能没开不解析节点/信号、不泄露存在性)。emit_signal 仍在方法黑名单里,
451
549
  ## 本 handler 是唯一的、被门控的发信号入口;通用 call 面 emit_signal 始终被拒。
@@ -65,6 +65,27 @@ class _SubPathLeafFixture extends Node:
65
65
  var open_dict: Dictionary = {"foo": 7}
66
66
 
67
67
 
68
+ # issue #167:handle_call_method 的 typed-arg coerce / fail-loud 用 fixture。
69
+ # 各方法的形参带类型声明,get_method_list() 会汇报对应 TYPE_*,从而走 _coerce_call_arg。
70
+ class _CallFixture extends Node:
71
+ var last_rect: Rect2 = Rect2()
72
+ var rect_called: bool = false
73
+ # 复合 Variant 形参:Array → Rect2 应被 coerce。命名避开方法黑名单(≠ "set")。
74
+ func set_wander_rect(rect: Rect2) -> String:
75
+ last_rect = rect
76
+ rect_called = true
77
+ return "ok"
78
+ # 标量形参:Array 实参无法转换 → fail-loud。
79
+ func takes_int(n: int) -> int:
80
+ return n * 2
81
+ # 带默认值的可选尾参:required=1,给 1 个实参应放行(b 取默认 5)。
82
+ func takes_two(a: int, b: int = 5) -> int:
83
+ return a + b
84
+ # untyped(Variant) 形参:Array 实参应原样透传,不被误拦。
85
+ func takes_any(v):
86
+ return v
87
+
88
+
68
89
  var _api: Node
69
90
  var _target: Node
70
91
 
@@ -690,6 +711,137 @@ func test_call_method_nonexistent_returns_1003() -> void:
690
711
  assert_eq(int(result.error.code), 1003)
691
712
 
692
713
 
714
+ # ── handle_call_method typed-arg coerce / fail-loud(issue #167) ──────
715
+
716
+ func test_call_method_coerces_array_to_rect2() -> void:
717
+ # 核心修复:typed Rect2 形参收到 JSON Array → coerce 成 Rect2 并真正调用,
718
+ # 不再静默 ok:true/result:null(#164 live WANDER 演示的假阳性)。
719
+ var fixture: _CallFixture = _CallFixture.new()
720
+ fixture.name = "WanderTarget"
721
+ add_child_autofree(fixture)
722
+ var result: Dictionary = _api.handle_call_method({
723
+ "path": str(fixture.get_path()),
724
+ "method": "set_wander_rect",
725
+ "args": [[1, 2, 3, 4]],
726
+ })
727
+ assert_does_not_have(result, "error")
728
+ assert_eq(str(result.get("result")), "ok")
729
+ assert_true(fixture.rect_called)
730
+ assert_eq(fixture.last_rect, Rect2(1, 2, 3, 4))
731
+
732
+
733
+ func test_call_method_rect2_wrong_length_fails_loud() -> void:
734
+ # Rect2 期望 4 个元素,给 3 → -32602,且方法**没**被调用(不能假成功)。
735
+ var fixture: _CallFixture = _CallFixture.new()
736
+ fixture.name = "BadLenWanderTarget"
737
+ add_child_autofree(fixture)
738
+ var result: Dictionary = _api.handle_call_method({
739
+ "path": str(fixture.get_path()),
740
+ "method": "set_wander_rect",
741
+ "args": [[1, 2, 3]],
742
+ })
743
+ assert_has(result, "error")
744
+ assert_eq(int(result.error.code), -32602)
745
+ assert_string_contains(str(result.error.message), "Rect2")
746
+ assert_false(fixture.rect_called)
747
+
748
+
749
+ func test_call_method_rect2_non_numeric_element_fails_loud() -> void:
750
+ # Rect2 元素须全数字,含字符串 → -32602,方法不被调用。
751
+ var fixture: _CallFixture = _CallFixture.new()
752
+ fixture.name = "BadElemWanderTarget"
753
+ add_child_autofree(fixture)
754
+ var result: Dictionary = _api.handle_call_method({
755
+ "path": str(fixture.get_path()),
756
+ "method": "set_wander_rect",
757
+ "args": [[1, 2, "x", 4]],
758
+ })
759
+ assert_has(result, "error")
760
+ assert_eq(int(result.error.code), -32602)
761
+ assert_string_contains(str(result.error.message), "Rect2")
762
+ assert_false(fixture.rect_called)
763
+
764
+
765
+ func test_call_method_too_few_args_fails_loud() -> void:
766
+ # takes_int 必填 1 个,给 0 → -32602(连 callv 都不发,避免引擎错误 + 假 ok:true)。
767
+ var fixture: _CallFixture = _CallFixture.new()
768
+ fixture.name = "TooFewTarget"
769
+ add_child_autofree(fixture)
770
+ var result: Dictionary = _api.handle_call_method({
771
+ "path": str(fixture.get_path()),
772
+ "method": "takes_int",
773
+ "args": [],
774
+ })
775
+ assert_has(result, "error")
776
+ assert_eq(int(result.error.code), -32602)
777
+ assert_string_contains(str(result.error.message), "argument count")
778
+
779
+
780
+ func test_call_method_too_many_args_fails_loud() -> void:
781
+ # takes_int 声明 1 个(非 vararg),给 2 → -32602。
782
+ var fixture: _CallFixture = _CallFixture.new()
783
+ fixture.name = "TooManyTarget"
784
+ add_child_autofree(fixture)
785
+ var result: Dictionary = _api.handle_call_method({
786
+ "path": str(fixture.get_path()),
787
+ "method": "takes_int",
788
+ "args": [1, 2],
789
+ })
790
+ assert_has(result, "error")
791
+ assert_eq(int(result.error.code), -32602)
792
+ assert_string_contains(str(result.error.message), "argument count")
793
+
794
+
795
+ func test_call_method_optional_arg_omitted_ok() -> void:
796
+ # takes_two(a, b=5):只给 a=3 应放行,b 取默认 → 返 8。
797
+ var fixture: _CallFixture = _CallFixture.new()
798
+ fixture.name = "OptionalArgTarget"
799
+ add_child_autofree(fixture)
800
+ var result: Dictionary = _api.handle_call_method({
801
+ "path": str(fixture.get_path()),
802
+ "method": "takes_two",
803
+ "args": [3],
804
+ })
805
+ assert_does_not_have(result, "error")
806
+ assert_eq(int(result.get("result")), 8)
807
+
808
+
809
+ func test_call_method_array_to_scalar_param_fails_loud() -> void:
810
+ # 标量 int 形参收到 Array → callv 会 "Cannot convert" 静默失败,故 pre-empt 成 -32602。
811
+ var fixture: _CallFixture = _CallFixture.new()
812
+ fixture.name = "ArrayToScalarTarget"
813
+ add_child_autofree(fixture)
814
+ var result: Dictionary = _api.handle_call_method({
815
+ "path": str(fixture.get_path()),
816
+ "method": "takes_int",
817
+ "args": [[1, 2]],
818
+ })
819
+ assert_has(result, "error")
820
+ assert_eq(int(result.error.code), -32602)
821
+
822
+
823
+ func test_call_method_untyped_param_passes_array_through() -> void:
824
+ # 防过度拦截:untyped(Variant) 形参的 Array 实参应原样透传,方法收到该数组。
825
+ var fixture: _CallFixture = _CallFixture.new()
826
+ fixture.name = "UntypedArgTarget"
827
+ add_child_autofree(fixture)
828
+ var result: Dictionary = _api.handle_call_method({
829
+ "path": str(fixture.get_path()),
830
+ "method": "takes_any",
831
+ "args": [[1, 2, 3]],
832
+ })
833
+ assert_does_not_have(result, "error")
834
+ assert_eq(result.get("result"), [1, 2, 3])
835
+
836
+
837
+ func test_prepare_call_args_vararg_not_count_rejected() -> void:
838
+ # vararg 方法(Node.rpc 是变参)尾部可接任意多实参,数量校验不能误拦。
839
+ # 直接打 _prepare_call_args(不实际 callv,避免 rpc 副作用),断言无 error、实参保留。
840
+ var prepared: Dictionary = _api._prepare_call_args(_target, "rpc", ["some_method", 1, 2])
841
+ assert_does_not_have(prepared, "error")
842
+ assert_eq((prepared.get("args") as Array).size(), 3)
843
+
844
+
693
845
  # ── _build_tree 节点总数上限(防 outbound buffer 超限) ───────────────
694
846
 
695
847
  func test_build_tree_short_circuits_above_node_limit() -> void:
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.4.1'
22
+ __version_tuple__ = version_tuple = (0, 4, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -240,7 +240,7 @@ Three numeric ranges cohabit in `error.code`. Knowing which is which lets you de
240
240
  |---|---|
241
241
  | `-32600` | Malformed request (missing / non-string `method`). Bug in client; should never reach an agent. |
242
242
  | `-32601` | Unknown method name. Bug; means client + plugin versions drifted. |
243
- | `-32602` | Invalid params: missing required field, blocked property/method (security blacklist), out-of-range value, value-type mismatch on `set` (e.g. `Vector2` property given an array of wrong length / non-numeric elements), node-isn't-clickable (e.g. you `click`'d a `Node2D`), or `hold` given `duration ≤ 0` (use `press` for an indefinite hold). Don't retry; the request shape is wrong. |
243
+ | `-32602` | Invalid params: missing required field, blocked property/method (security blacklist), out-of-range value, value-type mismatch on `set` (e.g. `Vector2` property given an array of wrong length / non-numeric elements), value-type or argument-count mismatch on `call` (typed-parameter coercion failure, a JSON array given to a scalar/Object parameter, or the wrong number of args), node-isn't-clickable (e.g. you `click`'d a `Node2D`), or `hold` given `duration ≤ 0` (use `press` for an indefinite hold). Don't retry; the request shape is wrong. |
244
244
 
245
245
  **Client-side (CLI / GameClient) — `-1xxx`:**
246
246
 
@@ -363,6 +363,8 @@ godot-cli-control call /root/Game start_game 1 '"easy"' # int 1, string "easy
363
363
 
364
364
  So `position '[100, 200]'` → `Vector2(100, 200)`, `transform '[1,0,0, 0,1,0, 0,0,1, 10,20,30]'` → `Transform3D(IDENTITY, (10,20,30))`. Wrong length or non-numeric elements fail loud with `-32602 "value type mismatch ..."` instead of silently setting `(0, 0)` like pre-0.2.5 versions did.
365
365
 
366
+ **`call` typed arguments use the same coercion (+ arg-count / type checks).** When you `call <node> <method>` and pass a JSON array for a parameter the method declares as a compound Variant (`Rect2`, `Vector2`, `Color`, …), the server reads the method signature (`get_method_list()`) and coerces it exactly like `set` above — e.g. `call /root/Mob enable_wander '[0, 0, 640, 480]'` reaches `func enable_wander(rect: Rect2)` as a real `Rect2`. Mismatches now **fail loud with `-32602`** instead of the old silent `ok: true / result: null`: wrong array length or a non-numeric element (`"value type mismatch ..."`), a JSON array handed to a scalar / Object parameter (`"cannot convert JSON Array ..."`), or the wrong number of arguments (`"argument count mismatch ..."`, which honors optional/default parameters and varargs). Scalar arguments (numbers, strings, bools) pass straight through as before. This closes the footgun where `callv` printed `Cannot convert argument ...` to the engine log but the RPC still reported success.
367
+
366
368
  **Sub-path + Array also fails loud.** `set <node> transform:origin '[10, 20, 30]'` is rejected with `-32602 "sub-path + Array is not supported"`: Godot's `Object.set("transform:origin", Array)` silently drops the Array (origin stays at `(0,0,0)`) — same class of footgun as the strict Variant checks above, so the server pre-empts it. Sub-paths are scalar-only (`set <node> position:x 1.8`); to write a whole compound Variant use the top-level Array form above.
367
369
 
368
370
  **Sub-path leaf typo also fails loud (symmetric with `get`).** `set <node> position:zz 5` is rejected with `1002` listing the valid leaves, instead of silently dropping the write and returning `{"success": true}` (Godot's `set_indexed` no-ops an unknown leaf — an "I thought I wrote it but the value never changed" footgun). Closed compound types and per-level nesting are exactly the same set as the `get` side; open/dynamic types (Dictionary, Array, Object) still pass through unchecked.
@@ -456,6 +458,29 @@ Key constraints:
456
458
 
457
459
  **Project-level defaults** (`.cli_control/config.json`, optional): set `{"idle_timeout": "30m"}` so `daemon start` / `run` auto-quit after idle without re-typing `--idle-timeout` every time. It's read only when you don't pass `--idle-timeout` (default `0`); an explicit flag wins. Bad JSON / a malformed duration there surfaces as a `-1003` usage error (exit 64).
458
460
 
461
+ #### Recipe: warm-then-record (filming a pre-evolved world)
462
+
463
+ To record a demo that begins from a *long-evolved* world (e.g. many in-game years of simulation), you **cannot** fast-forward inside the recording: under `--record` Movie Maker renders a fixed `--fps × time_scale` frames per game-second, so raising `Engine.time_scale` only fast-forwards the *captured footage* instead of cheaply skipping ahead. Use a two-daemon **split-record** flow — evolve + save the world in a non-recording daemon, then load + perform in a recording one.
464
+
465
+ 1. **Warm up (non-recording daemon).** Start a normal daemon (`--headless` is fine for pure simulation) and push the world to the target state with `time-scale N` or, better, the game's own sim-speed control. Poll a game-side progress value to know when you have arrived — e.g. `wait-property /root/GameState day == <target>` (substitute your game's own node/field).
466
+ 2. **Persist + stop.** Call the game's save entry point to flush the evolved world to a (sandbox) save slot — `call /root/WorldService save_session` (substitute your own) — then `daemon stop` the warm-up daemon.
467
+ 3. **Load + record (recording daemon).** Start a `--record` daemon and `run` a script that loads the saved world, waits for it to settle, then drives the camera/player at `time_scale = 1`:
468
+ ```python
469
+ def run(bridge):
470
+ bridge.call("/root/SceneRouter", "continue_game") # load saved world (game-side API)
471
+ bridge.wait_node("/root/World") # continue_game settles after `await world.ready`
472
+ bridge.set("/root/World/Player", "global_position", [x, y]) # reset the start AFTER the load settles
473
+ # ... scripted walk / clicks, captured to the movie ...
474
+ ```
475
+
476
+ Known gotchas (all hit in real downstream demo work):
477
+
478
+ - **Headless `time_scale` overshoots.** Headless runs uncapped, so a high `time_scale` blows past the target in a single tick. Prefer a GUI daemon (frame-rate capped) and poll a game-side counter to stop precisely, or warm up in small `time-scale` steps, re-checking progress between each.
479
+ - **SIGTERM close-save can clobber your save.** If the game saves on quit, the graceful exit `daemon stop` performs may overwrite the slot with the *warm-up* player position. Confirm the save landed before stopping, disable on-quit save during warm-up, or treat the recording-side `continue_game` load as the source of truth and reset the start there.
480
+ - **`continue_game` settles asynchronously.** The loaded world places entities only after `await world.ready`, not synchronously on the call. In the recording script, `wait` / `wait-node` / `wait-property` for the world to be ready **before** overriding the start point — a position set too early is overwritten when the load resolves.
481
+
482
+ Boundary: to fast-forward *within* a recording, drive the game's **own** sim-speed (which leaves Movie Maker's fixed `--fps × time_scale` frame budget untouched), not engine `time_scale` (which fast-forwards the footage).
483
+
459
484
  ## CLI reference
460
485
 
461
486
  <!-- BEGIN cli_help (auto-generated by godot-cli-control init) -->
@@ -618,6 +643,7 @@ pytest_plugins = ["godot_cli_control.pytest_plugin"]
618
643
  - **`wait-signal` must be armed before the action that fires it.** Every `godot-cli-control` invocation is a fresh process — if you fire the signal before `wait-signal` connects, you'll always timeout. **Preferred (issue #155):** use `--trigger` to arm and fire on the *same* connection: `godot-cli-control wait-signal /root/Area door_opened 3 --trigger 'tap interact'` — the server connects the signal handler first (armed), then your trigger runs, then the result returns. No race, no shell gymnastics. The result envelope includes `trigger_result` with the trigger subcommand's return value (only present when the trigger actually ran). **When using `--trigger`, `timeout` covers only the signal wait, not the trigger's execution time** (issue #172) — the client tells the server to start the deadline *after* your trigger finishes, so slow triggers like `combo` or `drag` no longer eat into the budget. **Fallback (if you can't use --trigger):** `godot-cli-control wait-signal /root/A my_signal & godot-cli-control tap jump; wait` — background the wait *before* triggering. `--trigger` only accepts a single RPC subcommand; for multi-step triggers use `combo` as the `--trigger` subcommand (e.g. `--trigger 'combo --steps-json ...'`).
619
644
  - **Replace magic `wait-time` sleeps with `wait-prop` or `wait-frames`.** Fixed `wait-time 0.3` guesses are fragile — they're too long when the game is fast, too short under load. Prefer: `wait-prop /root/Player on_floor true` (wait for state) or `wait-frames 4` (wait for a specific number of frames to render). These are more reliable and often 2-10× faster.
620
645
  - **`get` on a compound Variant returns an array + type — you can round-trip it straight into `set`.** `get /root/Player position` returns `{"value": [-2480.0, 1400.0], "type": "Vector2"}`. That `value` array is the exact format `set` accepts: `set /root/Player position '[-2480.0, 1400.0]'`. No conversion needed.
646
+ - **`call` into a method with typed params just works — and fails loud if it can't.** A JSON array passed to a `Rect2` / `Vector2` / `Color` (etc.) parameter is coerced from the method signature (`call /root/Mob enable_wander '[0,0,640,480]'` → `enable_wander(rect: Rect2)`), the same as `set`. If the array is the wrong length, has a non-numeric element, is handed to a scalar parameter, or you pass the wrong number of args, you get `-32602` — **not** a fake `ok: true / result: null`. So a successful-looking `call` that does nothing is no longer possible for typed methods: trust the success envelope.
621
647
  - **Arrays/Dicts nested inside compound Variants encode as arrays but carry no `type` field — use a sub-path to read a typed leaf.** For example, a `Dictionary` property that happens to contain a `Vector3` will give you an untyped array. If you need the type, use `get <node> mydict:somekey` to read the leaf directly and get its type.
622
648
  - **Sub-path leaf typo on a closed compound type fails loud — on both `get` and `set`.** Closed types are Vector2/3/4 (incl. `i` variants), Color, Rect2/Rect2i, Transform2D/Transform3D, Basis, Plane, Quaternion, AABB, and Projection — a typo'd leaf returns `1002` listing the valid leaves, e.g. `get /root/Node position:typo` → `{"ok": false, "error": {"code": 1002, "message": "Sub-path leaf not found: position:typo (valid leaves: x, y)"}}`. `set /root/Node position:zz 5` is rejected the same way instead of silently dropping the write and returning `{"success": true}`. Nested paths validate per level, so `get /root/Sprite transform:basis:typo` also fails loud. Only **open/dynamic types** (Dictionary, Array, Object) still return `{"value": null}` (or silently pass a `set` through) for an unknown leaf — their keys/members can't be enumerated, so validating would risk false positives. On `get`, `1002` is also returned if the part before `":"` doesn't exist as a top-level property.
623
649
  - **Python API (`bridge.get_property` / `bridge.get_properties`) returns bare values only — no `type` field.** These convenience methods strip the `type` from the server response to reduce boilerplate. When you need the `type` field (e.g. to distinguish `Vector2` from a plain 2-element array), go through `client.request("get_property", {"path": ..., "property": ...})` directly.