godotengine / godot

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

.NET 6: Hot reload throws exceptions #67719

Open wihrl opened 2 years ago

wihrl commented 2 years ago

Godot version

4.0 beta 3

System information

Win 11

Issue description

Applying method changes while debugging a C# project results in errors. The method still gets updated, but the resulting error can be seen in the hot reload output in the Output panel of Visual Studio. [Error] Applying updates failed: Microsoft.VisualStudio.HotReload.Components.DeltaApplier.ManagedDeltaApplierFailedToConnectException: Exception of type 'Microsoft.VisualStudio.HotReload.Components.DeltaApplier.ManagedDeltaApplierFailedToConnectException' was thrown.

In Rider, this manifests itself as only being able to apply the changes once. image

Steps to reproduce

Create a new C# project and setup debugging.

In the .csproj:

<PropertyGroup>
    <StartAction>Program</StartAction>
    <StartProgram>path to godot</StartProgram>
  </PropertyGroup>

launchSettings.json:

{
  "profiles": {
    "HotReloadTest": {
      "commandName": "Project",
      "commandLineArgs": "--path C:\\Projects\\HotReloadTest"
    }
  }
}

Then start debugging, change a method and apply the change.

Minimal reproduction project

No response

jolexxa commented 2 years ago

Just out of curiosity, does anyone know if Godot 4 is supposed to work with dotnet's hot reloading capabilities out of the box? If so, is that documented anywhere I can read about it?

wihrl commented 2 years ago

Probably not documented anywhere, but I'd assume that since Godot 4 is now just using the regular .NET runtime, there's nothing preventing this from working. I mean, it already kind of works in VS.

wihrl commented 1 year ago

I found a workaround by using reflection to poll MethodBody.LocalSignatureMetadataToken.

/// <summary>
/// Using the standard .NET Hot Reload support does not work with Godot.
/// This class serves as a workaround to detect changes in code using reflection.
/// Replace with [assembly: System.Reflection.Metadata.MetadataUpdateHandler)] once it works.
/// </summary>
public static class HotReloadChecker
{
    static readonly Dictionary<(string, string), int> _tokens = new();

    public static bool Changed<T>(T instance, string methodName)
    {
        var type = instance.GetType();
        var method = type.GetMethod(methodName)?.GetMethodBody() ??
                     throw new Exception($"Method {methodName} not found");
        var token = method.LocalSignatureMetadataToken;

        var key = (type.Name, methodName);

        bool changed = false;
        if (_tokens.TryGetValue(key, out var lastToken))
            changed = lastToken != token;

        _tokens[key] = token;
        return changed;
    }
} 
nathanpovo commented 1 year ago

I found a workaround by using reflection to poll MethodBody.LocalSignatureMetadataToken.

/// <summary>
/// Using the standard .NET Hot Reload support does not work with Godot.
/// This class serves as a workaround to detect changes in code using reflection.
/// Replace with [assembly: System.Reflection.Metadata.MetadataUpdateHandler)] once it works.
/// </summary>
public static class HotReloadChecker
{
    static readonly Dictionary<(string, string), int> _tokens = new();

    public static bool Changed<T>(T instance, string methodName)
    {
        var type = instance.GetType();
        var method = type.GetMethod(methodName)?.GetMethodBody() ??
                     throw new Exception($"Method {methodName} not found");
        var token = method.LocalSignatureMetadataToken;

        var key = (type.Name, methodName);

        bool changed = false;
        if (_tokens.TryGetValue(key, out var lastToken))
            changed = lastToken != token;

        _tokens[key] = token;
        return changed;
    }
} 

@wihrl how is this supposed to be used? Where should this code be placed?

I cannot find any documentation that mentions creating a class like this to aid in hot reloading. What I did find is the MetadataUpdateHandlerAttribute attribute (see info on this here, and here) but I cannot see how your code can be converted to fit this attribute class.

qwe321qwe321qwe321 commented 6 months ago

@wihrl how is this supposed to be used? Where should this code be placed?

I cannot find any documentation that mentions creating a class like this to aid in hot reloading. What I did find is the MetadataUpdateHandlerAttribute attribute (see info on this here, and here) but I cannot see how your code can be converted to fit this attribute class.

[MetadataUpdateHandlerAttribute] is the official way to catch the event from hot-reloading. But it still doesn't work for Godot. (.NET hot reload itself has worked on Godot already since .NET 6.0)

I guess the @wihrl 's workaround was that you can call this method to check if the specific method has been hot-reloaded. To kinda catch the event of hot reloading, you'll need to call this method all the time.

qwe321qwe321qwe321 commented 6 months ago

I got a better workaround to me that using the custom attribute to register the notification and its callback.

HotReloadManager.cs

using Godot;
using System;
using System.Collections.Generic;
using System.Reflection;
using WatchMethod = (System.Reflection.MethodInfo watched, HotReloadCallback callback);

public delegate void HotReloadCallback(string methodName);

/// <summary>
/// Using the standard .NET Hot Reload support does not work with Godot.
/// This class serves as a workaround to detect changes in code using MethodInfo.GetMethodBody().LocalSignatureMetadataToken.
/// Put [NotifyHotReload] attribute to the method you want to catch the notification when it is hot reloaded.
/// And remember to put a script to call HotReloadManager.CheckHotReloaded() to update the notification system.
/// 
/// Replace with [assembly: System.Reflection.Metadata.MetadataUpdateHandler)] once it works.
/// </summary>
public static class HotReloadManager {
    static readonly Dictionary<MethodInfo, int> _methodTokens = new();
    static List<WatchMethod> _watchMethods = new List<WatchMethod>();
    static HotReloadManager() {
        Initialize();
    }

    static void Initialize() {
        GD.Print($"Initialize {nameof(HotReloadManager)}");

        _watchMethods.Clear();
        foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) {
            foreach (var type in assembly.GetTypes()) {
                foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)) {
                    var attribute = method.GetCustomAttribute<NotifyHotReloadAttribute>();
                    if (attribute == null) {
                        continue;
                    }
                    string callbackName = attribute.CallbackMethodName;
                    if (string.IsNullOrEmpty(callbackName)) {
                        continue;
                    }
                    // Callback must be static.
                    MethodInfo callback = type.GetMethod(
                        callbackName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static,
                        types: [typeof(string)] // The first parameter is the name of the reloaded method.
                        );
                    if (callback == null) {
                        continue;
                    }
                    _watchMethods.Add((method, callback.CreateDelegate<HotReloadCallback>()));
                }
            }
        }
        foreach (var method in _watchMethods) {
            int token = method.watched.GetMethodBody()?.LocalSignatureMetadataToken ??
                throw new Exception($"Method {method.watched}'s MethodBody not found");
            _methodTokens.Add(method.watched, token);
            GD.Print($"{method.watched.DeclaringType}.{method.watched.Name} registered hot-reloading.");
        }
    }

    public static bool CheckHotReloaded() {
        bool changed = false;
        foreach (var method in _watchMethods) {
            int token = method.watched.GetMethodBody()?.LocalSignatureMetadataToken ??
                throw new Exception($"Method {method}'s MethodBody not found");
            if (token != _methodTokens[method.watched]) {
                GD.Print($"{method.watched.DeclaringType}.{method.watched.Name} changed!");
                // Update the token.
                _methodTokens[method.watched] = token;
                changed = true;
                // Callback.
                method.callback.Invoke(method.watched.Name);
            }
        }
        return changed;
    }
}

[AttributeUsage(AttributeTargets.Method)]
public class NotifyHotReloadAttribute : Attribute {
    public string CallbackMethodName;
    public NotifyHotReloadAttribute(string callbackMethodName) {
        CallbackMethodName = callbackMethodName;
    }
}

Usage

using Godot;

public partial class HotReloadTest : Label {
    private static int _hotReloadTimes = 0;
    private static string _hotReloadMessage = "";

    public override void _Process(double delta) {
        // You should put HotReloadChecker.CheckHotReloaded() call in its own class to update it as often as you like.
        HotReloadManager.CheckHotReloaded();

        // Update text on the game ui.
        Text = $"Hot Reload Times: {_hotReloadTimes}\n" +
            $"MyMethod: {MyMethod()}\n" +
            $"MyMethod2: {MyMethod2()}\n" +
            _hotReloadMessage;
    }

    [NotifyHotReload(nameof(OnHotReloadCallback))]
    public string MyMethod() {
        return "Method 1";
    }

    [NotifyHotReload(nameof(OnHotReloadCallback))]
    public string MyMethod2() {
        return "Method 2";
    }

    // The callback method must be static and with 1 string parameter.
    private static void OnHotReloadCallback(string methodName) {
        _hotReloadTimes++;
        _hotReloadMessage = $"{methodName} has been changed!";
    }
}

https://github.com/godotengine/godot/assets/23000374/90a872af-2e75-45c5-bf96-42393dad78bf