godot
godot copied to clipboard
@export_tool_button breaks when changing the script contents
Tested versions
- Reproducible in 4.4dev3, the web editor
System information
I'm running MacOS, Firefox, web editor
Issue description
After adding a new @export_tool_button to a script, all tool buttons break. Seems to be fixable only by reloading the entire project.
Steps to reproduce
My script:
@tool
extends Node
@export_tool_button("Hello world")
var hello_world := func():
print("Hello world")
The button appears in the UI. When I click it, the error message appears:
The value of property "hello_world" is Nil, but Callable was expected.
"Soft reload tool script" doesn't help.
After reloading the whole project though, it works as expected:
Hello world
But add another tool button, and both of them break again:
@tool
extends Node
@export_tool_button("Hello world")
var hello_world := func():
print("Hello world")
@export_tool_button("Hello world2")
var hello_world2 := func():
print("Hello world2")
Now the first one errors with
Tool button action "<invalid lambda>" is an invalid callable.
and the second one errors with
The value of property "hello_world2" is Nil, but Callable was expected.
Reloading the whole project again, again, fixes both problems.
Minimal reproduction project (MRP)
Sorry I couldn't figure out how to attach the web project, and I running out of time right now. It's basically the default project with just one scene and one script, that I pasted above. I'm confident this will show up in any project.
Makes me wonder: was it really a good idea to serialize a callable here? IMO just marking a function name as a tool button would be enough and simpler
@caphindsight See https://github.com/godotengine/godot-proposals/issues/2149#issuecomment-2394937456.
I tested the issue, it has several parts.
1. Hot reloading only breaks when adding new lambdas. It does not break if you add a new variable (even an exported one) that does not contain a lambda. The class variable initializers are compiled in a single implicit_initializer function, and we have the following limitation:
https://github.com/godotengine/godot/blob/4c4e67334412f73c9deba5e5d29afa8651418af2/modules/gdscript/gdscript_compiler.cpp#L3203-L3206
If you swap two variables, then after saving the script and reselecting the node, the buttons will swap, but will perform actions in the old order. CC @rune-scape
2. Newly added properties are null by default until you restart the scene/editor, because on hot reload the implicit initializer and constructor are not called again. I'll see what we can do about that, though it might be a bit counterintuitive due to the nature of hot reloading.
I have an idea about default values, and I noticed a bit of refactoring in that area would be desirable (member_default_values and member_default_values_cache, GDScriptCompiler::convert_to_initializer_type() and GDScriptAnalyzer::make_variable_default_value() look like duplicates, GDScript::get_property_default_value() doesn't always return the correct value even in editor builds).
3. Regarding the use of lambda callables, the documentation already warns that it is not advisable for RefCounted classes, we could expand the note.
Note: Avoid storing lambda callables in member variables of RefCounted-based classes (e.g. resources), as this can lead to memory leaks. Use only method callables and optionally Callable.bind or Callable.unbind.
If you use method callables, you will not have problem 1, only 2 (but this can be fixed by restarting the scene/editor).
Alternatively, you can use a getter instead of assigning a lambda to a variable. This is similar to the feature that will be implemented for C# (see #97894), but a bit more verbose since GDScript does not have the => syntactic sugar.
@export_tool_button("Hello world")
var hello_world:
get:
return func (): print("Hello world")
- See also godotengine/godot-proposals#10360.
With this proposal we could reduce the boilerplate:
@export_tool_button("Hello world")
var hello_world => func (): print("Hello world")
But I'm not sure that's a good enough justification, since the problem is limited to hot reloading and lambda callables; method callables seem pretty robust. Tool scripts may not be comfortable to develop and debug, but once you're done there shouldn't be any problems.
Sorry, what's the recommended way to use @export_tool_button that doesn't have any of the problems you listed?
Lambda doesn't work, function name still doesn't work because of 2, does getter work always?
Are there plans to fix function name or lambda?
i have an idea on how to fix 1. by naming lambdas if they are assigned to a variable or member and then hotswapping uniquely named lambdas in the same script
lambda hot swapping is mostly a guessing game for unnamed lambdas, as it too hard to reliably tell where the text has been moved, if it even makes sense to replace, and the new function might not even be compatible
hotswapping lambdas is a delicate process, as there are some internal properties (captures and use_self and others) that can prevent the function pointers from being swapped out inside a callable. this doesnt apply to the getter version, bc the lambda callable is newly created with the correct reloaded function every time
I'm getting similar issues when trying to use tool buttons on 4.4.beta4. I always get an error message until I restart/reload the project.
I'm getting similar issues when trying to use tool buttons on 4.4.beta4. I always get an error message until I restart/reload the project.
Please file a release blocking bug report, otherwise it's likely to ship broken with 4.4.
@akien-mga this bug makes tool buttons unusable, should we roll back tool buttons in 4.4 if this is 4.5?
Since reloading the project seems to fix the issue, I think the feature is still worth having, even if hot reloading is broken.
Hot reloading for tool scripts has never been very reliable, especially with lambdas.
I get that the issue is a bit frustrating but it's something that will mostly be a hassle while developing custom tooling. Once the custom tooling is made, the whole team can use it just fine.
- See also #103394.
This is one of the reasons why it is hard for us to fix this, let alone hot reload lambdas. So using member function callable or variable getter is more reliable than lambda. Maybe we should introduce short getter syntax, see #99199.
The faster workaround for me is to start and quit the game, instead of reloading the project. Could we put this in the documentation? Also, this suggests that there's something in the code path for starting/stopping that reloads the tool script and is faster than reloading the whole project.
i would like to mention that using the lambda-in-getter syntax:
@export_tool_button("Hello world")
var hello_world:
get: return func(): print("Hello world")
this avoids the issue completely! even lambda hot-reloading ones. and its not too much more to type, even if it isn't the thing i'd immediately jump to
the intuitive one using a lambda as initializer:
@export_tool_button("Hello world")
var hello_world := func(): print("Hello world")
has 2 issues:
- the member is null after adding a new member or renaming the member then hot reloading
- reordering 2 members initialized with unnamed lambdas can get them mixed up after hot reloading
and i have a PR that solves the latter issue, https://github.com/godotengine/godot/pull/98221
Workaround which might help some people:
I found the same issue when adding the method name to the export_tool_button like this:
@export_tool_button("Regenerate") var test = generate_map
This was working, after reloading the project. But then when I changed the method name it was calling 'generate_world' i found it was still calling the original method. I'm sure a reload would fix it.
My workaround, and what I now do whenever I use export_tool_button, is to keep the method name the same, then change what that method in turn calls. Something like this, which works every time:
@export_tool_button("Regenerate") var test = onGenerateButtonClicked
func onGenerateButtonClicked();
callSomeOtherMethod() # I can then just update this each time I want to call different code
Hi ! It seems i have done exactly what is written in the documentation : here but then (even with multiples reloads) i have :
Which i really don't understand :'(
if anyone one has any tips ? (P.S. if it's not the right place, tell me and i will move it to wherever it need to be)
@scoutantho it's basically shipped broken at the moment. You can try the getter syntax described above in this thread, maybe that'll work for now.
Add the notice that this functionality is broken to the documentation please, lots of time was wasted of many people because someone couldn't write a paragraph in 30 seconds.