В предыдущих двух статьях я разбирал 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-параметров. Проблема: во время ReconstructNode → AllocateDefaultPins линки ещё не восстановлены (восстанавливаются ПОСЛЕ). Нужен кэш.
Первая версия:
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-таска - это, конечно, стрельба из пушки по воробьям. Но мне в кайф. А для тех, кто следит за серией - здесь видно, как вся эта теория ложится в конкретный плагин.
