В предыдущих двух статьях я разбирал K2Node - как устроены ноды Blueprint изнутри - и Blueprint VM: байткод, опкоды, стековую машину. Следующая на очереди про рефлексию: UClass, UFunction, FProperty и вся система метаданных, на которой стоит движок. (Её выпуск запланирован на 17.03, 10:00).

Готовясь к ней, я решил, что лучше всего разобраться в теме поможет практика. И тут подвернулся юзкейс: мне нужен был способ сконфигурировать вызов произвольной функции в редакторе и выполнить его в рантайме. Без хардкода, без кодогенерации, без десятка одинаковых обёрток. Так появился FunctionHandler - плагин для UE 5.6, в котором пригодилось всё, о чём я писал раньше: CustomThunk'и, ExpandNode, работа с FFrame и MostRecentProperty.

Эта статья - про то, как всё сошлось в одном плагине, какие решения сработали, и на какие грабли я наступал.


Задача

В моём проекте есть StateTree, управляющий поведением объектов на уровне. Типичное действие: вызвать функцию на акторе - сменить состояние двери, включить свет, запустить анимацию. Стандартный подход - писать кастомный FStateTreeTask на каждый вызов. К двадцатому таску становится понятно, что половина из них отличаются только вызовом разных функций.

Хотелось:

  • Один универсальный таск, где в Details-панели выбираешь класс, функцию и параметры

  • Нативные виджеты для каждого типа параметра (enum dropdown, GameplayTag picker, asset selector и тд.)

  • Сериализуемость - чтобы конфигурация переживала Save/Load и работала с DataAsset'ами

Идея не уникальна. Знакомый делал нечто похожее для системы инвентаря - хранил отложенные вызовы эффектов в DataAsset'ах предметов. Я решил собрать это в утилитарный плагин.


FFunctionHandler: ядро

Центральная структура плагина - FFunctionHandler. Сериализуемый USTRUCT, который хранит три вещи:

USTRUCT(BlueprintType)
struct FFunctionHandler
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere)
    TSubclassOf<UObject> TargetClass;

    UPROPERTY(EditAnywhere)
    FName FunctionName;

    UPROPERTY(EditAnywhere)
    TMap<FName, FString> ParameterValues;
};

TMap<FName, FString> вместо FStructOnScope - ключевое решение. Значения параметров хранятся в ExportText-формате. Это текстовое представление, которое Unreal использует повсеместно: copy/paste в Details-панели, конфиги, сериализация. Каждый FProperty умеет экспортировать и импортировать своё значение через ExportTextItem_Direct / ImportText_Direct.

Почему не FStructOnScope:

  • FStructOnScope держит указатель на UScriptStruct. UFunction - не UScriptStruct. Можно обмануть через каст, но время жизни UFunction привязано к загруженному модулю

  • TMap<FName, FString> сериализуется нативно, реплицируется, не зависит от lifetime ничего

FName FunctionName вместо указателя на UFunction - по той же причине. Резолвится на целевом объекте при вызове. Один и тот же handler можно вызвать на любом объекте нужного класса.

Вызов в рантайме

ResolveFunction(TargetObject)          // FName → UFunction*
→ Malloc(ParamsSize) + InitializeStruct // аллокация фрейма параметров
→ ImportText_Direct для каждого параметра // TMap → typed values
→ ProcessEvent(Function, ParamFrame)   // вызов
→ DestroyStruct + Free                 // очистка

Ничего экзотического - стандартный паттерн вызова UFunction из C++. Вся фишка в том, как параметры попадают в TMap и обратно.


Property Customization: три попытки

Параметры нужно редактировать в Details-панели. С нативными виджетами - не текстовые поля с ExportText-строками, а настоящие enum dropdown'ы, GameplayTag picker'ы, color picker'ы.

Попытка 1: SEditableTextBox

Каждый параметр - текстовое поле. Пользователь видит (R=1.0,G=0.0,B=0.0,A=1.0) вместо color picker'а. Работает, но непригодно для людей.

Попытка 2: AddExternalStructureProperty

FStructOnScope(UFunction*) + IDetailChildrenBuilder::AddExternalStructureProperty. Компилируется, даже отрисовывает что-то. Но не подхватывает зарегистрированные IPropertyTypeCustomization для вложенных типов. Причина: UFunction - не UScriptStruct (а вообще-то UStruct, об этом подробнее в статье про рефлексию), движок не резолвит кастомизации.

Бонусный краш: AddExternalStructureProperty возвращает IDetailPropertyRow* (nullable pointer), не IDetailPropertyRow& (reference). Первая версия разыменовывала nullptr.

Попытка 3: IStructureDetailsView (финальная)

FPropertyEditorModule::CreateStructureDetailView - создаёт полноценный Details View с рабочим pipeline кастомизации. Но порядок вызовов критичен:

CreateStructureDetailView(Args, StructArgs, nullptr)  // без данных!
→ SetIsPropertyVisibleDelegate(IsInputParameter)       // фильтр
→ SetStructureData(ParameterBuffer)                    // теперь данные

Если создать view сразу с данными - фильтр не применяется, Return Value видимый. Ещё один обязательный параметр: bForceHiddenPropertyVisibility = true. Параметры UFunction имеют CPF_Parm, но не CPF_Edit - Details View по умолчанию их прячет.

Синхронизация обратно в TMap - через GetOnFinishedChangingPropertiesDelegate. При любом изменении в view экспортируем весь буфер через ExportTextItem_Direct. NotifyPreChange/NotifyPostChange для undo/redo.


K2Node и украшательства: пять нод, два модуля

Для Blueprint-графа я написал пять K2Node:

  • Make Function Handler - создаёт FFunctionHandler с типизированными input-пинами

  • Execute Handler - вызывает handler на объекте, генерирует typed output-пины для return value

  • Set Handler Parameters - batch-запись всех параметров

  • Get Handler Parameters - batch-чтение

  • Set Handler Parameter - запись одного параметра с wildcard value-пином

Все ноды живут в отдельном UncookedOnly- модуле. Не в Editor - потому что K2Node из Editor-модуля при размещении в рантайм-Blueprint'е даёт ошибку "K2 Node from Editor Only module placed in runtime Blueprint". UncookedOnly доступен в редакторе, но гарантированно исключается при cook. Именно этот тип модуля Epic использует для BlueprintGraph. (Да, об этом мы уже говорили в статье про K2Node).

Но Editor модуль тоже нам нужен, но как раз для упомянутой выше Property Customization.

Указываем класс
Указываем класс
теперь можем вызвать любую функцию
теперь можем вызвать любую функцию

Грабля: UPROPERTY() vs mutable

UK2Node_ExecuteFunctionHandler резолвит сигнатуру фу��кции из подключённой переменной (через CDO), чтобы создать типизированные output-пины для return value и out-параметров. Проблема: во время ReconstructNodeAllocateDefaultPins линки ещё не восстановлены (восстанавливаются ПОСЛЕ). Нужен кэш.

Первая версия:

mutable TSubclassOf<UObject> CachedTargetClass;
mutable FName CachedFunctionName;

Работает ровно до Save/Load. После открытия Blueprint'а кэш пуст → output-пины не создаются → getter-ноды не спавнятся в ExpandNode → CustomThunk GetResultByName отсутствует в байткоде.

Я потратил четыре итерации на отладку thunk'а, прежде чем сделал дамп байткода и увидел, что GetResultByName там просто нет. Thunk был корректен с первой попытки. Проблема: mutable-поля без UPROPERTY() не сериализуются. А mutable несовместим с UPROPERTY() - UHT откажется компилировать.

Фикс:

UPROPERTY()
TSubclassOf<UObject> CachedTargetClass;

UPROPERTY()
FName CachedFunctionName;

Убрать mutable, убрать const с методов. Да, пара часов отладки такой глупости, но ценный опыт.

Грабля: порядок расширения нод

Компилятор Blueprint расширяет (вызывает Expand) ноды в порядке зависимостей - сначала те, от которых зависят другие, потом зависимые. Make-нода стоит раньше по цепочке, Execute - позже.

Когда Make-нода расширяется, она вызывает BreakAllNodeLinks(), удаляет себя из графа и заменяется промежуточной UK2Node_CallFunction, оборачивающей InternalMakeFunctionHandler. Связи перебрасываются на неё. Следом расширяется Execute-нода. Она идёт по LinkedTo[0]->GetOwningNode(), чтобы узнать сигнатуру функции - но там уже не UK2Node_MakeFunctionHandler, а UK2Node_CallFunction. Cast к Make-ноде возвращает nullptr. Сигнатура неизвестна, output-пины не создаются.

Решение - два уровня:

Первый - кэш с UPROPERTY(), о котором шла речь выше. Если Execute-нода когда-то успешно зарезолвила сигнатуру, она запоминает класс и имя функции. Этот кэш переживает Save/Load/Compile.

Второй - разрешение через промежуточную ноду. Если Make уже расширилась и вместо неё стоит UK2Node_CallFunction - это не тупик. Промежуточная нода вызывает InternalMakeFunctionHandler, а значит у неё есть пины с классом и именем функции в дефолтных значениях. Читаем оттуда напрямую, без каста к Make-ноде. Второй уровень работает в любом порядке расширения и не зависит от того, обновлялся ли кэш.

Грабля: Promote to Variable крашит движок

Этот баг нашёлся прямо в процессе написания статьи.
Promote to Variable на пине любой из вышеупомянутых нод - краш с assertion OwningNode failed в EdGraphPin.h:424.

Цепочка: пользователь жмёт Promote -> движок создаёт UK2Node_VariableGet -> вызывает AutowireNewNode(), чтобы соединить новую ноду с исходным пином.

Но между созданием ноды и autowire движок триггерит PinConnectionListChanged на нашей ноде. Наша реализация синхронно вызывала RefreshFromHandler() ->ReconstructNode(). ReconstructNode уничтожает все пины и создаёт новые. Указатель, который AutowireNewNode держит в руках, теперь указывает на мёртвую память. Решение - отложить реконструкцию на следующий тик:

    TWeakObjectPtr<UK2Node_FunctionHandlerBase> WeakThis(this);
    FTSTicker::GetCoreTicker().AddTicker(
        FTickerDelegate::CreateLambda([WeakThis](float) -> bool
        {
            if (UK2Node_FunctionHandlerBase* Node = WeakThis.Get())
            {
                Node->ReconstructNode();
            }
            return false;
        }));

TWeakObjectPtr страхует от случая, когда нода удалена до срабатывания тика. Стандартные ноды движка (Switch, Select) страдали тем же - в их коде можно найти аналогичное решение.


CustomThunk'и: wildcard-параметры в Blueprint VM

Для типизированных пинов с wildcard-поведением используются CustomThunk'и - C++ функции, которые напрямую работают со стеком Blueprint VM вместо стандартной упаковки параметров.

Ключевой паттерн - StepCompiledIn<FProperty> с обнулением MostRecentProperty:

// InternalSetParameter: запись typed значения в TMap
Stack.MostRecentPropertyAddress = nullptr;
Stack.MostRecentProperty = nullptr;        // ОБНУЛЕНИЕ КРИТИЧНО!
Stack.StepCompiledIn<FProperty>(nullptr);   // wildcard input
void* ValuePtr = Stack.MostRecentPropertyAddress;
FProperty* ValueProp = Stack.MostRecentProperty;

Почему обнуление обязательно: не все опкоды обновляют MostRecentProperty. Литеральные опкоды (execNameConst, execIntConst) записывают значение, но не трогают MostRecentProperty. Если не обнулить перед StepCompiledIn - получишь stale значение от предыдущего шага. В моём случае MostRecentProperty содержал FObjectProperty (8 байт) от предыдущего параметра, а реальный параметр был int32 (4 байта). Type check проваливался, значение писалось во временный буфер и уничтожалось.

Если вы читали статью про Blueprint VM - это именно тот FFrame::MostRecentProperty, который я там разбирал. Теперь видно, зачем про него нужно знать.


StateTree: один таск

Всё вышеперечисленное сходится в одном юзкейсе, ради которого всё затевалось. FStateTreeCallFunctionTask - StateTree-таск, который в EnterState вызывает ExecuteFunctionByHandler на Target Object.

В Details-панели StateTree:

Да, всё ради этого ¯\_(ツ)_/¯
Да, всё ради этого ¯\_(ツ)_/¯

Итого

Писать CustomThunk'и и K2Node'ы ради одного StateTree-таска - это, конечно, стрельба из пушки по воробьям. Но мне в кайф. А для тех, кто следит за серией - здесь видно, как вся эта теория ложится в конкретный плагин.