How to allocate delegates with parameters as output pins in a K2Node ?

Hi there, the issue I’m encountering seems pretty simple to solve but I don’t have the necessary knowledge in K2Nodes to find a proper solution.
I’m simply trying to connect a delegate (passing an int) to an output pin in a K2Node.


1. Where I am right now

  • I’m creating a custom blueprint callable node that replicates a simple for loop. So far, this works fine and looks like that in Unreal:

  • As the node look a bit ugly, I want to map it to a custom K2Node. I’m in this process right now, and it’s where things get messed up for me.

    • I successfully completed the visual and the AllocateDefaultPins() part. So the node now looks a bit more like a conventional for loop:

2. The doubts I have

  • First off, I’m not so sure if the way I do things when creating my for loop is the right one -with the delegates-. Friendly reminder, I’m still fairly new to c++ so be kind please.

    • If there is a better way to tackle that I’d love to now!
  • While working on the ExpandNode() function in the K2Node, I can’t quite figure out how to properly connect the delegates with their parameters to the output pins and Index integer.


3. Some of my code

Here’s how I declare my dynamic delegates with parameters:

/** Delegate for the loop body, passing the current index */
DECLARE_DYNAMIC_DELEGATE_OneParam(FLoopBodyDelegate, int32, Index);

/** Delegate for loop completion */
DECLARE_DYNAMIC_DELEGATE(FLoopCompletedDelegate);

/** Delegate for loop cancellation */
DECLARE_DYNAMIC_DELEGATE(FLoopCanceledDelegate);

So the two parameters I’m trying to connect are Index from the first OnLoopBody delegate, and CanceledAtIndex from the third delegate OnTaskCanceled.

So the UFUNCTION, public and private declarations looks like:

public:
    UFUNCTION(BlueprintCallable, Category = "Editor Task|Slow Loops")
    static USlowTaskNodes* SlowForLoop(
        int32 StartIndex,
        int32 EndIndex,
        bool bCanCancelTask,
        FLoopBodyDelegate LoopBody,
        FLoopCompletedDelegate OnCompleted,
        FLoopCanceledDelegate OnCanceled);

    virtual void Activate() override;

private:
    int32 StartIndex;
    int32 EndIndex;
    bool bCanCancelTask;
    FLoopBodyDelegate LoopBodyDelegate;
    FLoopCompletedDelegate OnCompletedDelegate;
    FLoopCanceledDelegate OnCanceledDelegate;

The loop logic seems to be working fine as is:

USlowTaskNodes* USlowTaskNodes::SlowForLoop(
    int32 InStartIndex,
    int32 InEndIndex,
    bool bInCanCancelTask,
    FLoopBodyDelegate InLoopBody,
    FLoopCompletedDelegate InOnCompleted,
    FLoopCanceledDelegate InOnCanceled)
{
    USlowTaskNodes* Node = NewObject<USlowTaskNodes>();
    Node->StartIndex = InStartIndex;
    Node->EndIndex = InEndIndex;
    Node->bCanCancelTask = bInCanCancelTask;
    Node->LoopBodyDelegate = InLoopBody;
    Node->OnCompletedDelegate = InOnCompleted;
    Node->OnCanceledDelegate = InOnCanceled;
    return Node;
}

void USlowTaskNodes::Activate()
{
    // Calculate the total number of iterations
    const int32 TotalIterations = EndIndex - StartIndex + 1;

    // Only create and show the dialog if running in the editor
    bool bShowDialog = GIsEditor;

    // Create a slow task
    FScopedSlowTask SlowTask(TotalIterations, FText::FromString(TEXT("Processing...")), bShowDialog);
    if (bShowDialog)
    {
        SlowTask.MakeDialog(bCanCancelTask); // Show cancel button if allowed
    }

    // Execute the loop
    bool bIsCanceled = false;
    for (int32 Index = StartIndex; Index <= EndIndex; ++Index)
    {
        // Check if the task was canceled (only relevant if bShowDialog is true)
        if (bShowDialog && SlowTask.ShouldCancel())
        {
            bIsCanceled = true;
            break;
        }

        // Update the slow task progress
        SlowTask.EnterProgressFrame(1,
            FText::Format(LOCTEXT("SlowTask", "Processing... {0} / {1}"), FText::AsNumber(Index - StartIndex + 1),
                FText::AsNumber(EndIndex - StartIndex + 1)));

        // Call the loop body delegate
        if (LoopBodyDelegate.IsBound())
        {
            LoopBodyDelegate.Execute(Index);
        }
    }

    // Handle task completion or cancellation
    if (bIsCanceled)
    {
        // Trigger the OnCanceled delegate
        if (OnCanceledDelegate.IsBound())
        {
            OnCanceledDelegate.Execute();
        }
    }
    else
    {
        // Trigger the OnCompleted delegate
        if (OnCompletedDelegate.IsBound())
        {
            OnCompletedDelegate.Execute();
        }
    }

    // Mark as ready for destruction
    SetReadyToDestroy();
}

On the K2Node side, the AllocateDefaultPins() function successfully binds everything:

void USlowForLoop::AllocateDefaultPins()
{
    const UEdGraphSchema_K2* K2Schema = GetDefault<UEdGraphSchema_K2>();

    // Execution Input Pin
    CreatePin(EGPD_Input, K2Schema->PC_Exec, FName("Execute"));

    // Input Pins
    CreatePin(EGPD_Input, K2Schema->PC_Int, FName("Start Index"));
    CreatePin(EGPD_Input, K2Schema->PC_Int, FName("End Index"));
    CreatePin(EGPD_Input, K2Schema->PC_Boolean, FName("Can Cancel Task"));

    // Output Pins
    CreatePin(EGPD_Output, K2Schema->PC_Exec, FName("Loop Body"));
    CreatePin(EGPD_Output, K2Schema->PC_Int, FName("Index"));
    CreatePin(EGPD_Output, K2Schema->PC_Exec, FName("Completed"));
    CreatePin(EGPD_Output, K2Schema->PC_Exec, FName("Canceled"));
}

Now in the ExpandNode() function, things get dirty. I’ve managed to make everything work except the 3 delegates (one having the index parameter to pass -LoopBody-).
That’s where I’m stuck right now:

void USlowForLoop::ExpandNode(FKismetCompilerContext& CompilerContext, UEdGraph* SourceGraph)
{
    Super::ExpandNode(CompilerContext, SourceGraph);

    const UEdGraphSchema_K2* Schema = CompilerContext.GetSchema();
    if (!Schema)
    {
        CompilerContext.MessageLog.Error(*LOCTEXT("SchemaError", "SlowForLoop: Schema not found. @@").ToString(), this);
        return;
    }

    // Track whether all operations are error-free
    bool bIsErrorFree = true;

    // Retrieve the SlowForLoop function
    UFunction* Function = USlowTaskNodes::StaticClass()->FindFunctionByName(GET_FUNCTION_NAME_CHECKED(USlowTaskNodes, SlowForLoop));
    if (!Function)
    {
        CompilerContext.MessageLog.Error(*LOCTEXT("FunctionError", "SlowForLoop: Function 'SlowForLoop' not found in USlowTaskNodes. @@").ToString(), this);
        return;
    }

    // Create CallFunction node
    UK2Node_CallFunction* CallSlowForLoop = CompilerContext.SpawnIntermediateNode<UK2Node_CallFunction>(this, SourceGraph);
    CallSlowForLoop->SetFromFunction(Function);
    CallSlowForLoop->AllocateDefaultPins();

    // Move input pins
    bIsErrorFree &= CompilerContext.MovePinLinksToIntermediate(*FindPinChecked(TEXT("Start Index")), *CallSlowForLoop->FindPinChecked(TEXT("StartIndex"))).CanSafeConnect();
    bIsErrorFree &= CompilerContext.MovePinLinksToIntermediate(*FindPinChecked(TEXT("End Index")), *CallSlowForLoop->FindPinChecked(TEXT("EndIndex"))).CanSafeConnect();
    bIsErrorFree &= CompilerContext.MovePinLinksToIntermediate(*FindPinChecked(TEXT("Can Cancel Task")), *CallSlowForLoop->FindPinChecked(TEXT("bCanCancelTask"))).CanSafeConnect();

    // Move execution input pin
    bIsErrorFree &= CompilerContext.MovePinLinksToIntermediate(*FindPinChecked(TEXT("Execute")), *CallSlowForLoop->GetExecPin()).CanSafeConnect();

    /////////////////////////////////////////////////////////
    // TODO: Move delegates (LoopBody, OnCompleted, OnCanceled) and Index output pins
    /////////////////////////////////////////////////////////
    
    // If any operation failed, log an error
    if (!bIsErrorFree)
    {
        CompilerContext.MessageLog.Error(*LOCTEXT("InternalConnectionError", "SlowForLoop: Internal connection error occurred while expanding node. @@").ToString(), this);
    }

    // Cleanup
    BreakAllNodeLinks();
}

So if you have ever found a solution for that, or have a logic rework idea, please give your insights, it would be much appreciated!