Skip to content

Commit 9b4a29b

Browse files
dsarnoclaude
andcommitted
Address retroactive review of PRs #17 and #21
Nine follow-ups from a retroactive code review of the animation_* tool family (#17) and the cyberpunk HUD friction fixes (#21). All 388 Python + 345 GDScript tests pass; smoke-tested live against the editor. 1. node_create scene_path now uses PackedScene.GEN_EDIT_STATE_INSTANCE so the editor treats the result as a real scene instance (foldout icon, .tscn stores a reference, not an exploded subtree). Removes the _set_owner_recursive call that was running outside the undo action and also broke the instance link by forcing descendant ownership. 2. Animation track undo no longer caches the baseline track_count at do time - it resolves the index via anim.find_track(path, type) at undo time. Robust to any history interleaving. New test stresses this path. 3. _coerce_value_for_track now errors with INVALID_PARAMS when the target node exists but the property doesnt - previously the raw value passed through and produced garbage keyframes at playback. Matches the handlers own docstring. 4. add_method_track validates keyframe args is an Array and method is a non-empty string. Previously a typed-assign crash or silent garbage. 5. animation_delete now uses _resolve_animation to locate the owning library, matching the read-side symmetry with animation_get / animation_play. Delete from non-default libraries works end-to-end. 6. theme_set_stylebox_flat flattened from 27 parameters to a nested-dict API: border / corners / margins / shadow each take {all, sides} with side-specific keys overriding all. Unknown keys fail loudly. Breaking change - tool was only merged in #21 and has no external clients yet. 7. project_stop replaces the fixed 150ms sleep with a bounded poll on session.readiness. Reaches truth as soon as the existing readiness_changed event arrives (~17ms) instead of blanket-sleeping 150ms, with a 1s timeout for a hung play process. 8. ui_build_layout pseudo-property tests now read back via get_theme_*_override (not the fallback get_theme_* getters), per the CLAUDE.md "assert on the stored Variant" rule. Added missing theme_override_styles/ test. 9. signal_connect distinguishes declared-but-uninstantiated autoloads from genuinely missing nodes, with a clear error pointing users at @onready/runtime-connect as the workaround. Previous "not found" error masked the real reason most runtime-only autoloads cant be connected at edit time. Also updates CLAUDE.md with reference patterns for find_track-at-undo, GEN_EDIT_STATE_INSTANCE, and get_theme_*_override readback tests. Deferred to a follow-up PR: plugin dispatcher coroutine support + readiness Option C (plugin-side await). That refactor touches the hot path every command flows through and deserves its own bisect-friendly landing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 361eeb0 commit 9b4a29b

15 files changed

Lines changed: 771 additions & 315 deletions

File tree

CLAUDE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,35 @@ JSON dicts like `{"r":1,"g":0,"b":0,"a":1}` only become `Color` / `Vector2` / `V
185185

186186
GDScript tests that just assert `track_count == 1` will pass even when coercion is broken. **Always read back via `track_get_key_value(idx, k)` and assert `value is Color` / `value is Vector3` / etc.** `test_animation.gd` `test_add_property_track_coerces_vector3_dict` is the reference pattern. The same rule applies to any future handler that takes JSON values intended to land as typed Variants in the scene.
187187

188+
Same principle for theme override pseudo-properties on Controls: use `get_theme_color_override`, `get_theme_constant_override`, `get_theme_font_size_override`, `get_theme_stylebox_override` in tests — **not** the fallback `get_theme_color` getters — so a broken override silently resolving via the theme fallback can't mask a bug. `test_ui.gd` `test_build_layout_theme_override_*` are the reference pattern.
189+
190+
### Auto-generated indices: look up at undo time, not do time
191+
192+
When a write tool mutates a resource whose index is assigned by Godot (`Animation.add_track` returns an int index, same for track keys, `MultiMesh.instance_count`, etc.), do **not** capture that index at do time and reuse it in the undo callable. Any other mutation landing between the do and the undo makes the index stale — the undo will then remove the wrong element (or error).
193+
194+
Instead, undo via a helper that resolves the index at undo time via a stable lookup:
195+
196+
```gdscript
197+
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE)
198+
199+
func _undo_remove_track_by_path(anim: Animation, path: String, type: int) -> void:
200+
var idx := anim.find_track(NodePath(path), type)
201+
if idx >= 0:
202+
anim.remove_track(idx)
203+
```
204+
205+
See `animation_handler.gd::_undo_remove_track_by_path` for the reference pattern. Cover with a test that interleaves a second mutation between the do and undo of the first (`test_animation.gd::test_add_property_track_undo_survives_interleaving`).
206+
207+
### Scene instancing: use GEN_EDIT_STATE_INSTANCE
208+
209+
When a tool instantiates a PackedScene into the edited scene, pass `PackedScene.GEN_EDIT_STATE_INSTANCE` to `instantiate()`:
210+
211+
```gdscript
212+
new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
213+
```
214+
215+
This makes Godot treat the result as a real scene instance: the root shows the foldout icon, the `.tscn` stores a reference to the sub-scene rather than an exploded subtree, and the instance can be swapped or toggled editable via the usual editor UI. Don't manually set descendant owners to your scene_root — descendants of a scene instance stay owned by their sub-scene; overriding that breaks the instance link. See `node_handler.gd::create_node`.
216+
188217
## Test coverage
189218

190219
100% code coverage for core features, always. Every tool, handler, and protocol path must have both:

plugin/addons/godot_ai/handlers/animation_handler.gd

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -156,26 +156,32 @@ func delete_animation(params: Dictionary) -> Dictionary:
156156
return resolved
157157
var player: AnimationPlayer = resolved.player
158158

159-
if not player.has_animation(anim_name):
160-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
161-
"Animation '%s' not found on player at %s" % [anim_name, player_path])
162-
163-
var library: AnimationLibrary = resolved.library
164-
if library == null:
165-
return McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, "No default library found")
166-
167-
var old_anim: Animation = library.get_animation(anim_name)
159+
# Use _resolve_animation so we can delete from ANY library, not just the
160+
# default. Mirrors the read-side symmetry with animation_get / animation_play
161+
# which already search all libraries via _resolve_animation.
162+
var anim_resolved := _resolve_animation(player, anim_name)
163+
if anim_resolved.has("error"):
164+
return anim_resolved
165+
var old_anim: Animation = anim_resolved.animation
166+
var library: AnimationLibrary = anim_resolved.library
167+
# Clip key within the owning library — strips the "libname/" prefix if the
168+
# caller passed a qualified name.
169+
var clip_key: String = anim_name
170+
var slash := anim_name.find("/")
171+
if slash >= 0:
172+
clip_key = anim_name.substr(slash + 1)
168173

169174
_undo_redo.create_action("MCP: Delete animation %s" % anim_name)
170-
_undo_redo.add_do_method(library, "remove_animation", anim_name)
171-
_undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim)
175+
_undo_redo.add_do_method(library, "remove_animation", clip_key)
176+
_undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim)
172177
_undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works
173178
_undo_redo.commit_action()
174179

175180
return {
176181
"data": {
177182
"player_path": player_path,
178183
"animation_name": anim_name,
184+
"library_key": anim_resolved.get("library_key", ""),
179185
"undoable": true,
180186
}
181187
}
@@ -237,11 +243,12 @@ func add_property_track(params: Dictionary) -> Dictionary:
237243
"transition": kf.get("transition", "linear"),
238244
})
239245

240-
var baseline := anim.get_track_count()
241-
242246
_undo_redo.create_action("MCP: Add property track %s to %s" % [track_path, anim_name])
243247
_undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes)
244-
_undo_redo.add_undo_method(anim, "remove_track", baseline)
248+
# Undo locates the track by (path, type) at undo time rather than caching
249+
# an index captured at do time. Cached indices go stale if any other track
250+
# mutation lands between do and undo (Godot editor, another MCP call, etc.)
251+
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE)
245252
_undo_redo.commit_action()
246253

247254
return {
@@ -251,7 +258,6 @@ func add_property_track(params: Dictionary) -> Dictionary:
251258
"track_path": track_path,
252259
"interpolation": interp_str,
253260
"keyframe_count": keyframes.size(),
254-
"track_index": baseline,
255261
"undoable": true,
256262
}
257263
}
@@ -306,6 +312,12 @@ func add_method_track(params: Dictionary) -> Dictionary:
306312
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Each keyframe must have a 'time' field")
307313
if not "method" in kf:
308314
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Each keyframe must have a 'method' field")
315+
var method_field = kf.get("method")
316+
if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty():
317+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string")
318+
if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY:
319+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
320+
"'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args"))))
309321

310322
var resolved := _resolve_player(player_path)
311323
if resolved.has("error"):
@@ -317,11 +329,10 @@ func add_method_track(params: Dictionary) -> Dictionary:
317329
return anim_resolved
318330
var anim: Animation = anim_resolved.animation
319331

320-
var baseline := anim.get_track_count()
321-
322332
_undo_redo.create_action("MCP: Add method track %s to %s" % [target_path, anim_name])
323333
_undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes)
324-
_undo_redo.add_undo_method(anim, "remove_track", baseline)
334+
# Undo locates the track by (path, type) at undo time — see add_property_track.
335+
_undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD)
325336
_undo_redo.commit_action()
326337

327338
return {
@@ -330,12 +341,21 @@ func add_method_track(params: Dictionary) -> Dictionary:
330341
"animation_name": anim_name,
331342
"target_node_path": target_path,
332343
"keyframe_count": keyframes.size(),
333-
"track_index": baseline,
334344
"undoable": true,
335345
}
336346
}
337347

338348

349+
## Remove a track identified by (path, type) at undo time. Robust to
350+
## history interleaving: if another track was added since the do, the
351+
## find_track call still resolves to the correct index. Returns silently
352+
## if the track is no longer present (e.g. a prior undo already removed it).
353+
func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void:
354+
var idx := anim.find_track(NodePath(track_path), track_type)
355+
if idx >= 0:
356+
anim.remove_track(idx)
357+
358+
339359
func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void:
340360
var idx := anim.add_track(Animation.TYPE_METHOD)
341361
anim.track_set_path(idx, NodePath(target_path))
@@ -862,15 +882,18 @@ static func _coerce_value_for_track(value: Variant, track_path: String, player:
862882

863883
var target: Node = root_node.get_node_or_null(node_part)
864884
if target == null:
885+
# Target node isn't in the scene yet — authoring-time path. Pass through.
865886
return {"ok": value}
866887

867888
for p in target.get_property_list():
868889
if p.name == prop_part:
869890
return _coerce_for_type(value, p.get("type", TYPE_NIL), prop_part)
870891

871-
# Property not found on current target — pass through. The caller may
872-
# plan to retarget the AnimationPlayer (set root_node) before playback.
873-
return {"ok": value}
892+
# Target exists but the property doesn't. Reject loudly — silently storing
893+
# the raw value here produces garbage keyframes at playback time.
894+
return {"error":
895+
"Property '%s' not found on target '%s' (class %s)" %
896+
[prop_part, node_part, target.get_class()]}
874897

875898

876899
## Coerce a single value to the given Godot variant type. Returns

plugin/addons/godot_ai/handlers/node_handler.gd

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,18 @@ func create_node(params: Dictionary) -> Dictionary:
3131

3232
if not scene_path.is_empty():
3333
# Scene instancing path — load and instantiate a PackedScene.
34+
# GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real
35+
# scene instance (foldout icon, the .tscn stores a reference instead of
36+
# an exploded subtree). Descendants remain owned by their sub-scene;
37+
# setting their owner to our scene_root would break the instance link.
3438
if not scene_path.begins_with("res://"):
3539
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "scene_path must start with res://")
3640
if not ResourceLoader.exists(scene_path):
3741
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Scene not found: %s" % scene_path)
3842
var packed_scene = ResourceLoader.load(scene_path)
3943
if packed_scene == null or not packed_scene is PackedScene:
4044
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Resource at %s is not a PackedScene" % scene_path)
41-
new_node = packed_scene.instantiate()
45+
new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
4246
if new_node == null:
4347
return McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path)
4448
else:
@@ -63,10 +67,6 @@ func create_node(params: Dictionary) -> Dictionary:
6367
_undo_redo.add_undo_method(parent, "remove_child", new_node)
6468
_undo_redo.commit_action()
6569

66-
# For instanced scenes, set owner on descendants so they serialize properly.
67-
if not scene_path.is_empty():
68-
_set_owner_recursive(new_node, scene_root)
69-
7070
var response := {
7171
"name": new_node.name,
7272
"type": new_node.get_class(),

plugin/addons/godot_ai/handlers/signal_handler.gd

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,15 @@ func _resolve_signal_params(params: Dictionary) -> Dictionary:
117117
if scene_root == null:
118118
return McpErrorCodes.make(McpErrorCodes.EDITOR_NOT_READY, "No scene open")
119119

120-
var source := ScenePath.resolve(params.path, scene_root)
121-
if source == null:
122-
source = _resolve_autoload(params.path)
123-
if source == null:
124-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Source node not found: %s (not in scene tree or autoloads)" % params.path)
125-
126-
var target := ScenePath.resolve(params.target, scene_root)
127-
if target == null:
128-
target = _resolve_autoload(params.target)
129-
if target == null:
130-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Target node not found: %s (not in scene tree or autoloads)" % params.target)
120+
var source_result := _resolve_node_or_autoload(params.path, scene_root, "Source")
121+
if source_result.has("error"):
122+
return source_result
123+
var source: Node = source_result.node
124+
125+
var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target")
126+
if target_result.has("error"):
127+
return target_result
128+
var target: Node = target_result.node
131129

132130
return {
133131
"source": source,
@@ -138,16 +136,34 @@ func _resolve_signal_params(params: Dictionary) -> Dictionary:
138136
}
139137

140138

141-
## Attempt to resolve a path as an autoload singleton.
142-
## Uses direct ProjectSettings lookup (O(1)) instead of scanning all properties.
143-
func _resolve_autoload(path: String) -> Node:
139+
## Resolve a path to a Node, with three distinct outcomes:
140+
## 1. Found in the edited scene tree → returns {node}
141+
## 2. Declared as an autoload AND instantiated at edit time → returns {node}
142+
## 3. Declared as an autoload but NOT instantiated at edit time → returns
143+
## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a
144+
## silent "not found" hides the real reason the connection can't be made.
145+
## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS.
146+
func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary:
147+
var node := ScenePath.resolve(path, scene_root)
148+
if node != null:
149+
return {"node": node}
150+
144151
var name := path.trim_prefix("/")
145-
if not ProjectSettings.has_setting("autoload/" + name):
146-
return null
147-
var tree := Engine.get_main_loop()
148-
if tree is SceneTree:
149-
return (tree as SceneTree).root.get_node_or_null(name)
150-
return null
152+
if ProjectSettings.has_setting("autoload/" + name):
153+
# Autoload is declared — see if the editor has it instanced.
154+
var tree := Engine.get_main_loop()
155+
if tree is SceneTree:
156+
var live := (tree as SceneTree).root.get_node_or_null(name)
157+
if live != null:
158+
return {"node": live}
159+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
160+
"%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] +
161+
"Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " +
162+
"Connect it from a script attached to the scene using @onready + connect(), " +
163+
"or enable editor-instancing for this autoload in Project Settings > Autoload.")
164+
165+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS,
166+
"%s node not found: %s (not in scene tree or autoloads)" % [role, path])
151167

152168

153169
func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary:

0 commit comments

Comments
 (0)