godotengine / godot

Godot Engine – Multi-platform 2D and 3D game engine
https://godotengine.org
MIT License
91.46k stars 21.27k forks source link

Revert icons in the Inspector don't show up for @export properties when using @tool scripts on Editor launch #98871

Open daroceg opened 3 weeks ago

daroceg commented 3 weeks ago

Tested versions

I tested this in 4.3 stable and 4.2 stable

System information

Godot v4.3.stable - Windows 10.0.19045 - Vulkan (Forward+) - dedicated NVIDIA GeForce GTX 1050 (NVIDIA; 31.0.15.3713) - Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz (8 Threads)

Issue description

Revert icons in the Inspector tab, those round arrows that sit on the left side of every property with a different value from default, is missing on startup for nodes that has scripts attached to them with @tool annotation. So scripts that run in the Editor. It is only missing for custom properties defined by the script. Anything that starts with @export. What solves the missing icons is a simple save of literally ANY scene. Keep in mind, I'm not talking about editing a script and having to save to show results, I'm talking about freshly launched Editor with literally no changes made to either 2D, 3D or Script. After saving and res-electing a node, all the revert icons appear. Not just in the saved scene, but in all scenes and all nodes, even the ones opened afterwards.

Steps to reproduce

Minimal reproduction project (MRP)

revert_icon_bug.zip

daroceg commented 3 weeks ago

I spent quite a bit of time trying to figure out what was causing this. I figured out if you put

func _property_can_revert(property: StringName) -> bool:
    return "variable_name"

in the same script your property is, that fixes it. Kind of. The button appears on startup, but does nothing. You also have to put

func _property_get_revert(property: StringName) -> Variant:
    return 5

for the button to work. You can automate the whole property collecting thing like this

func _init() -> void:
    for property in get_property_list():
        properties_default_values[property.name] = get(property.name)

func _property_can_revert(property: StringName) -> bool:
    return property in properties_default_values

func _property_get_revert(property: StringName) -> Variant:
    return properties_default_values.get(property)

but it's still a hack. You would still have to paste the same three functions in every single @tool script. If there is some way to write this thing once and then re-use it, that would be acceptable, but the issue is, these functions are not called by you. You can't offload them in their own separate script and save a reference to them. They just have to sit there in the same script your properties are, waiting to be called. By who? The Inspector, probably. I tried to put all kinds of print statements all around them to get a better understanding of what was going on. All I know is there is no function trail when they are called. Whatever calls them returns [ ] for a name (without the space in between).

daroceg commented 2 weeks ago

In an attempt to solve the issue in one place, rather than having to copy-paste the same 3 functions over and over again all over my project (like the solution mentioned in my previous comment), I came up with the idea to simply create a plugin that will automatically save a currently opened scene on editor startup, which would in theory fix the revert buttons issue without me having to manually navigate the scene menu or pressing Ctrl+S every time the editor loads.

The obvious thing would be to put get_editor_interface().save_scene() in func _enter_tree() in the main plugin script, however, plugins load well before scenes are loaded at startup, and even though I was technically able to save a scene (according to the output window), that didn't fix the missing revert icons issue, the way saving manually does. Clearly, save_scene() was saving too early in the editor booting process.

Over the course of my research on Godot's startup process i stumbled upon a fascinating bit of information. Godot actually already saves all scenes and scripts on editor startup. The option is even checked "on" by default. You can go to "Editor Settings / Run / Auto Save" and see for yourself. If you turn it off, your editor will boot up a bit faster, because it doesn't have to go through the saving process. But! If you turn it off you will be able to... open unsaved projects? According to the tool tip you will be able to open unsaved projects. I don't even know what that is. Wtf does that even mean? Anyways, I left it on. I never in my life opened an "unsaved file", and I'm not planing on starting now.

Going back to the plugin idea. I was searching for some built-in way to check if the editor was finished setting up. Something like get_editor_interface().editor_initialized signal. As far as I know, nothing like that exists. I rummaged through the editor documentation multiple times. As well as searching online. Nothing. If that exists, please let me know. And if it doesn't, please, add the signal.

I then opted for checking if the opened scene was finished loading. Knowing that the scene node structure loads from leaves to root (because of the potential child dependency on the parent), I could simply check if get_editor_interface().get_edited_scene_root().is_node_ready() == true. Surely, if the root node in the opened scene has finished loading, that would mean that the opened scene itself has finished loading, which would finally mean that the editor has finished loading. Right? WRONG! The editor doesn't load before or after the scene, you dummy. The editor loads all around the scene. Before the scene and after the scene. Some things are loaded before the scene loads, some things after the scene loads. In any case, bunch of errors. Sometimes it saves, sometimes not. Whether or not it does, doesn't solve the revert icons issue. Saving was still too early in the booting process.

OK, let's just wait 10 seconds when the plugin loads and then execute the saving code. Surely the editor and the scene will load in that time. So, at plugin _enter_tree(), we create a timer and save the opened scene on timer timeout. You can either await get_tree().create_timer(10).timeout and get_editor_interface().save_scene() on the next line, or by get_tree().create_timer(10).timeout.connect(func(): get_editor_interface().save_scene()") all in the same line. In both cases, the editor crashes. Not sure which one, but at some point, I had to manually edit the plugin file to comment out the save call in notepad outside of Godot AND delete Godot's AppData files, because after one of those crashes, the editor started to freeze at logo screen on startup and I couldn't even open the editor anymore. Why? I don't know. Something about calling save code on timer timeout causes Godot to crash and even corrupt some temp files.

After that I focused on the Godot file explorer. You know how it scans for changes every time you open Godot? There's a loading bar. That! And I remembered seeing some functions related to the file system when I was reading the editor documentation. My thought was this. If I could create a state machine that would check for file scanning, scene loading and file importing, then when all of those states give a green light, the editor MUST be loaded at that point. So I made one. A plugin script that checks file scanning and scene _ready() status. Thinking I would implement importing last. And guess what. Apparently there is no need, either for the scene checking or the importing checking. Apparently, after the files have been scanned by the file file system, the editor is fully loaded. If any imports are happening, the filesystem still returns is_scanning() to be true. And for some reason, even though you can't see the scene on your screen while the files are scanning and importing, the scene has already loaded.

Here's the code:

@tool
extends EditorPlugin

var state_machine: int = 0

func _enter_tree(): await _state_machine()
func _exit_tree(): pass

func _state_machine() -> void:
    await get_tree().process_frame
    var scanning = get_editor_interface().get_resource_filesystem().is_scanning()
    match state_machine:
        0:
            if scanning == false: await _state_machine(); return
            else: state_machine = 1; await _state_machine(); return
        1:
            if scanning == true: await _state_machine(); return
            else: state_machine = 2; await _state_machine(); return
        2:
            if get_editor_interface().get_edited_scene_root() == null: return
            var path := get_editor_interface().get_edited_scene_root().scene_file_path
            if path.is_empty(): return
            get_editor_interface().save_scene()
            get_editor_interface().reload_scene_from_path(path)

Seeing how there is no need to check anything else than the file system, I delved more deeply in that direction, hopefully to optimize the code further. I noticed there's also get_scanning_progress() method you can call. It returns a float, going from 0 to 1, and the best part is, it stays at 1 when finished. I can simply throw away the whole state machine idea, wait for one variable to to hit 1, save at that moment (actually, a frame later), and then terminate the process. Easy-peasy:

@tool
extends EditorPlugin

func _process(_delta: float) -> void:
    var progress := get_editor_interface().get_resource_filesystem().get_scanning_progress()
    var scanning := get_editor_interface().get_resource_filesystem().is_scanning()
    if progress == 1 and scanning == false:
        set_process(false); await get_tree().process_frame
        if get_editor_interface().get_edited_scene_root() == null: return
        if get_editor_interface().get_edited_scene_root().scene_file_path.is_empty(): return
        get_editor_interface().save_scene()
        var selected_nodes = get_editor_interface().get_selection().get_selected_nodes()
        get_editor_interface().get_selection().clear()
        if not selected_nodes: return
        await get_tree().process_frame
        get_editor_interface().edit_node(selected_nodes[0])

There you go. It's a simple loop that runs in the built in _process(delta) function. It checks the progress of the filesystem every frame and as soon as the filesystem is finished, the function immediately shuts itself off with set_process(false) and runs the main body of the code ONLY ONCE. That code saves the opened scene and reloads the inspector by deselecting and re-selecting the same node again (alternatively you can simply reload_scene_from_path(path) after saving, like I did in the state machine code, but this way saves a second or two). It also has a few safety checks in case there is no opened scene or if the scene doesn't have a file path. Also, the reason I'm checking both get_scanning_progress() AND is_scanning() is because without the latter, the importing section gets overlooked. This way, the code checks if the filesystem has finished AND if it's still scanning, meaning if something is still importing. Once those checks give a green light, ON THE NEXT FRAME (this is important) the editor is loaded, and you can safely save, and it works. The revert icons show up on editor startup.

As far as I'm concerned this is a fixed issue for me. I spent way too much time trying to find some kind of acceptable bandage for this problem. I'm not familiar enough with the whole C++ source code compiling thing. This issue should be fixed there, in the source code. Like I mentioned somewhere earlier, the main issue is that when the inspectors makes the call for the default values, it gets null as the returned answer, but only before the saving happens. It would be amazing if someone knowledgeable enough could look into that. Until that happens, this is not officially solved, I just found a few acceptable bandage solutions.