Это завершающая часть трилогии о внутренностях Unreal Engine. В первой мы разбирали K2Node и жизненный цикл Blueprint-нод. Во второй копнули глубже - Blueprint VM, байткод и dispatch loop. В небольшом отступлении я показал плагин FunctionHandler - сериализуемые вызовы функций через рефлексию - как практическое введение в тему. Сегодня ещё глубже: система рефлексии.
C++ ничего не знает о себе - и это проблема
C++ - язык со статической типизацией. После компиляции информация о структуре типов исчезает. Компилятор знает, что по смещению 0x120 от начала объекта лежит 4 байта, а по смещению 0x128 - FString. Но он не знает, как они называются, какого они типа в терминах движка, можно ли их сериализовать и нужно ли реплицировать.
А Unreal Engine хочет:
Сохранить объект в файл и загрузить обратно
Показать свойства в Details panel с категориями, слайдерами, подсказками
Дать доступ к свойствам и функциям нативного класса из Blueprint
Синхронизировать свойства по сети
Собрать мусор - отследить, какому объекту что принадлежит, и надо ли это держать в памяти
Все эти задачи требуют информации о типах в runtime. C++ её не даёт - значит, нужно сгенерировать самим.
Решение: Unreal Header Tool (UHT) - препроцессор, который парсит C++ заголовки, находит макросы UCLASS, UPROPERTY, UFUNCTION, UENUM, USTRUCT и генерирует код регистрации типов:
MyClass.h (ваш код) ↓ [UHT] ↓ MyClass.generated.h + MyClass.gen.cpp (макросы) (регистрация типов в runtime)
Если вы работали с Qt - концепция похожа на MOC (Meta-Object Compiler). Разница в масштабе: MOC генерирует сигналы/слоты, UHT генерирует полную модель типов.
4294 строки, которые вы никогда не читали
В моём проекте есть UAbilityComponent - компонент ability-системы с атрибутами здоровья и стамины, состояниями через GameplayTags, системой урона и спринта. В C++ это ~1000 строк: 3 структуры, 2 enum'а, ~50 UPROPERTY, ~50 UFUNCTION, 8 делегатов.
UHT сгенерировал из этого заголовка два файла:
AbilityComponent.generated.h - макросы, forward declarations, ~200 строк
AbilityComponent.gen.cpp - регистрация типов, 4294 строки!
Поле без UPROPERTY() (например, mutable float CachedEffectiveMin) полностью отсутствует в gen.cpp. UHT видит только то, что помечено макросами. Остальное для рефлексии не существует.
Вот фрагмент из AbilityComponent.gen.cpp - регистрация одного свойства CurrentValue в структуре FAbilityAttribute:
const UECodeGen_Private::FFloatPropertyParams Z_Construct_UScriptStruct_FAbilityAttribute_Statics::NewProp_CurrentValue = { "CurrentValue", // Имя nullptr, // RepNotifyFunc (EPropertyFlags)0x0010000000000015, // Флаги (что за число - разберём ниже) UECodeGen_Private::EPropertyGenFlags::Float, RF_Public|RF_Transient|RF_MarkAsNative, nullptr, nullptr, // ArrayDim function 1, // ArrayDim STRUCT_OFFSET(FAbilityAttribute, CurrentValue), // Смещение в памяти METADATA_PARAMS(...) // Метаданные };
Имя, флаги, смещение, метаданные. Именно это позволяет движку в runtime знать: "у FAbilityAttribute по смещению N лежит float, он называется CurrentValue, его можно редактировать в Details panel и читать из Blueprint".
Давайте подробнее разберем, что внутри generated-файлов и как всё это используется - от Details panel до байткода Blueprint VM.
Две иерархии: UObject-based и FField-based
Первое, что нужно понять: в UE5 существуют две параллельные системы для описания типов.
Историческая справка: UProperty → FProperty
До UE 4.25 всё было в одной иерархии:
UObject → UField → UStruct → UClass, UFunction, UScriptStruct → UEnum → UProperty → UIntProperty, UFloatProperty, ...
Каждое свойство было UObject. Класс с 50 свойствами создавал 50+ объектов, которые уч��ствовали в Garbage Collection. Это замедляло GC, увеличивало потребление памяти и создавало приличный overhead при итерации.
В UE 4.25 свойства вынесли из UObject-иерархии в отдельную, лёгкую:
UObject-based (управляется GC): UObject → UField → UStruct → UClass // UCLASS → UFunction // UFUNCTION → UScriptStruct // USTRUCT → UEnum // UENUM FField-based (ручное управление памятью): FField → FProperty → FIntProperty, FFloatProperty, FStructProperty, ...
FField не участвует в GC. Владеется структурой (UStruct), которая его создала, и удаляется вместе с ней. Вместо TArray для хранения дочерних элементов используется linked list через поле Next.
Для пользователя API изменился минимально - UProperty стал FProperty, UIntProperty стал FIntProperty, и так далее. Но внутри движка разница существенная.
Текущая архитектура


Ключевая связь между ними: UStruct содержит FField* ChildProperties - указатель на начало linked list свойств. Контейнер (UClass, UFunction, UScriptStruct) живёт в UObject-мире, а его содержимое (свойства) - в FField-мире.
FFieldClass - метакласс для FField
У FField есть своя мини-система типов. Каждый класс FField-иерархии описывается объектом FFieldClass:
class FFieldClass { FName Name; // "FIntProperty", "FStructProperty", ... uint64 Id; // Уникальный ID для быстрого cast uint64 CastFlags; // Битовая маска для IsChildOf EClassFlags ClassFlags; // Флаги класса FFieldClass* SuperClass; // Родительский FFieldClass FField* DefaultObject; // CDO-аналог FField* (*ConstructFn)(...); // Фабричная функция };
Интересен CastFlags. Каждому FFieldClass назначается уникальный бит. Проверка IsChildOf за O(1) через битовую маску:
// Field.h inline bool IsChildOf(const FFieldClass* InClass) const { const uint64 OtherClassId = InClass->GetId(); return OtherClassId ? !!(CastFlags & OtherClassId) : IsChildOf_Walk(InClass); }
Никакого обхода иерархии - одна побитовая операция. Это работает, потому что набор типов FField фиксирован и зашит в движок (пара десятков классов, все известны на этапе компиляции). Каждому можно выделить уникальный бит в uint64. Для UClass такой подход не годится - пользовательские классы создаются динамически, их набор не известен заранее.
Кстати, IsChildOf_Walk - fallback на линейный обход иерархии, если Id равен нулю (теоретический случай незарегистрированного класса):
bool IsChildOf_Walk(const FFieldClass* InBaseClass) const { for (const FFieldClass* TempField = this; TempField; TempField = TempField->GetSuperClass()) { if (TempField == InBaseClass) return true; } return false; }
На практике этот путь не вызывается - все FFieldClass зарегистрированы с ненулевым Id.
FFieldVariant - union для двух миров
Поскольку владелец FProperty может быть как UStruct (UObject), так и другим FField (например, inner property у FArrayProperty), нужен способ хранить оба варианта:
class FFieldVariant { union FFieldObjectUnion { FField* Field; UObject* Object; } Container; static constexpr uintptr_t UObjectMask = 0x1; inline bool IsUObject() const { return (uintptr_t)Container.Object & UObjectMask; } };
Младший бит указателя используется как признак типа. Это безопасно, потому что и UObject, и FField всегда выровнены минимум на 2 байта - младший бит адреса гарантированно равен нулю. Движок устанавливает этот бит в 1, когда хранит UObject*, и проверяет его для определения типа. Приём старый (используется, например, в V8 и многих реализациях Lisp), но здесь он позволяет обойтись без виртуальных вызовов и лишнего поля-дискриминатора.
В исходниках видно, как при создании FFieldVariant от UObject* бит устанавливается явно:
FFieldVariant(T&& InObject) { Container.Object = const_cast<UObject*>(ImplicitConv<const UObject*>(InObject)); Container.Object = (UObject*)((uintptr_t)Container.Object | UObjectMask); }
FProperty: анатомия свойства
FProperty - базовый класс для всех свойств. Каждый экземпляр описывает одно поле класса или структуры.
Основные поля
class FProperty : public FField { // Расположение в памяти int32 ArrayDim; // Размер static array (1 для обычных свойств) int32 ElementSize; // sizeof одного элемента int32 Offset_Internal; // Смещение от начала контейнера (заполняется в Link()) // Поведение EPropertyFlags PropertyFlags; // CPF_Edit, CPF_BlueprintVisible, CPF_Net, ... // Репликация uint16 RepIndex; // Индекс в массиве реплицируемых свойств FName RepNotifyFunc; // "OnRep_Health", "OnRep_States", ... // Linked lists (заполняются в Link(), in-memory only) FProperty* PropertyLinkNext; // Все свойства: derived → base FProperty* NextRef; // Только UObject-ссылки (для GC) FProperty* DestructorLinkNext; // Требующие деструкции FProperty* PostConstructLinkNext; // Требующие post-construct init };
Идентификация (FFieldClass, Owner, Next, NamePrivate) наследуется от FField.
Offset_Internal - ключ к доступу
Offset_Internal - то, что превращает рефлексию из базы данных в инструмент прямого доступа к памяти. Зная смещение, можно получить указатель на значение свойства внутри любого экземпляра.
В исходниках движка это выглядит так:
// UnrealType.h - ContainerVoidPtrToValuePtrInternal FORCEINLINE void* ContainerVoidPtrToValuePtrInternal(void* ContainerPtr, int32 ArrayIndex) const { return (uint8*)ContainerPtr + Offset_Internal + static_cast<size_t>(GetElementSize()) * ArrayIndex; }
Арифметика указателей - ничего больше. Для UObject-контейнеров есть параллельная версия ContainerUObjectPtrToValuePtrInternal - она делает то же самое, но с дополнительными проверками: валидность UObject, принадлежность свойства классу контейнера.
На практике используется шаблонная обёртка:
UAbilityComponent* Comp = ...; FProperty* HealthProp = Comp->GetClass()->FindPropertyByName(TEXT("HealthAttribute")); // Получаем указатель на HealthAttribute внутри Comp FAbilityAttribute* HealthPtr = HealthProp->ContainerPtrToValuePtr<FAbilityAttribute>(Comp); // Теперь можем читать и писать float CurrentHealth = HealthPtr->CurrentValue; HealthPtr->CurrentValue = 100.0f;
STRUCT_OFFSET в gen.cpp - это то, чем заполняется Offset_Internal при регистрации свойства. Вернёмся к нашему примеру:
// AbilityComponent.gen.cpp NewProp_CurrentValue = { "CurrentValue", nullptr, (EPropertyFlags)0x0010000000000015, ..., STRUCT_OFFSET(FAbilityAttribute, CurrentValue), // ← вот это ... };
STRUCT_OFFSET вычисляется компилятором в compile-time. UHT записывает его в сгенерированный код, а при загрузке модуля это значение попадает в Offset_Internal экземпляра FProperty.
EPropertyFlags (CPF_*) - поведение свойства
Флаги определяют, что можно делать со свойством. Расшифруем число 0x0010000000000015 из нашего примера:
0x0010000000000015 = CPF_Edit (0x0000000000000001) // Можно редактировать | CPF_BlueprintVisible(0x0000000000000004) // Доступно в Blueprint | CPF_BlueprintReadOnly(0x0000000000000010) // Только чтение в BP | CPF_NativeAccessSpecifierPublic(0x0010000000000000)
Это соответствует UPROPERTY(EditAnywhere, BlueprintReadOnly) в исходнике AbilityComponent.h:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Attribute") float CurrentValue;
Основные группы флагов:
// Видимость CPF_Edit // EditAnywhere / EditDefaultsOnly CPF_BlueprintVisible // Доступно в Blueprint CPF_BlueprintReadOnly // Только чтение в Blueprint // Сериализация CPF_Transient // Не сериализуется CPF_SaveGame // Включается в SaveGame // Репликация CPF_Net // Реплицируется CPF_RepNotify // Вызывает OnRep_* при изменении // Параметры функций (да, параметры - это тоже FProperty) CPF_Parm // Это параметр функции CPF_OutParm // Out-параметр CPF_ReturnParm // Return value // Вычисляемые (устанавливаются в Link()) CPF_IsPlainOldData // POD-тип, не требует конструктора/деструктора CPF_NoDestructor // Не требует деструкции CPF_ZeroConstructor // Инициализируется нулями
Иерархия наследников FProperty
Каждый тип FProperty знает, как сериализовать себя, сравнить два значения, экспортировать в строку и импортировать из неё:
FProperty ├── FNumericProperty // База для чисел │ ├── FByteProperty // uint8, TEnumAsByte │ ├── FInt8Property // int8 │ ├── FInt16Property // int16 │ ├── FIntProperty // int32 │ ├── FInt64Property // int64 │ ├── FUInt16Property // uint16 │ ├── FUInt32Property // uint32 │ ├── FUInt64Property // uint64 │ ├── FFloatProperty // float │ ├── FDoubleProperty // double │ └── FLargeWorldCoordinatesRealProperty // double / LWC ├── FBoolProperty // bool (особый случай - битовые поля) ├── FStrProperty // FString ├── FNameProperty // FName ├── FTextProperty // FText ├── FObjectPropertyBase // База для UObject-ссылок │ ├── FObjectProperty // TObjectPtr<UObject> │ │ └── FClassProperty // TObjectPtr<UClass> │ ├── FWeakObjectProperty // FWeakObjectPtr │ ├── FLazyObjectProperty // FLazyObjectPtr │ └── FSoftObjectProperty // FSoftObjectPtr │ └── FSoftClassProperty ├── FStructProperty // USTRUCT ├── FArrayProperty // TArray<> ├── FMapProperty // TMap<> ├── FSetProperty // TSet<> ├── FDelegateProperty // Делегаты ├── FMulticastDelegateProperty │ ├── FMulticastInlineDelegateProperty // FMulticastScriptDelegate │ └── FMulticastSparseDelegateProperty // FSparseDelegate ├── FInterfaceProperty // TScriptInterface<> ├── FEnumProperty // UENUM (enum class) └── FFieldPathProperty // FFieldPath
В gen.cpp это отражается напрямую. Для float CurrentValue создаётся FFloatPropertyParams. Для TArray<FAttributeModifier> MinModifiers - пара: FArrayPropertyParams (контейнер) + FStructPropertyParams (inner element):
// AbilityComponent.gen.cpp - TArray<FAttributeModifier> NewProp_MinModifiers_Inner = { "MinModifiers", ..., Z_Construct_UScriptStruct_FAttributeModifier, ... }; NewProp_MinModifiers = { "MinModifiers", ..., STRUCT_OFFSET(FAbilityAttribute, MinModifiers), ... };
Inner property описывает тип элемента массива. FArrayProperty хранит указатель на свой inner property и делегирует ему сериализацию каждого элемента.
Для EAttributeModifierMode Mode (enum class) тоже пара: FEnumPropertyParams + FBytePropertyParams (underlying type):
NewProp_Mode_Underlying = { "UnderlyingType", ..., 1, 0, nullptr, ... }; NewProp_Mode = { "Mode", ..., STRUCT_OFFSET(FAttributeModifier, Mode), Z_Construct_UEnum_ProtocolTerminate_EAttributeModifierMode, ... };
UStruct - контейнер свойств
UStruct - базовый класс для всего, что содержит свойства: классы (UClass), структуры (UScriptStruct) и функции (UFunction - параметры функции это тоже свойства).
Ключевые поля
class UStruct : public UField { UStruct* SuperStruct; // Родитель (для наследования) UField* Children; // Linked list дочерних UField (UFunction и т.д.) FField* ChildProperties; // Первое свойство (linked list через FField::Next) int32 PropertiesSize; // sizeof всех свойств вместе int16 MinAlignment; // alignof структуры TArray<uint8> Script; // Байткод Blueprint (для UFunction) // Linked lists (заполняются в Link(), in-memory only) FProperty* PropertyLink; // Все свойства: derived → base FProperty* RefLink; // Только UObject-ссылки (для GC) FProperty* DestructorLink; // Требующие деструкции FProperty* PostConstructLink; // Требующие post-construct init };
Четыре linked list и зачем они нужны
После загрузки свойства хранятся в ChildProperties как простой список через Next. Но разным подсистемам нужен доступ к разным подмножествам свойств:
PropertyLink - полный список от derived к base. Используется для итерации (TFieldIterator), сериализации, отображения в Details panel.
RefLink - только свойства, содержащие ссылки на UObject (FObjectProperty, FArrayProperty с inner FObjectProperty, и т.д.). Используется Garbage Collector'ом для трассировки ссылок. GC не нужно обходить все свойства - только те, что могут содержать UObject*. В исходниках Link() свойство попадает в RefLink, если
Property->ContainsObjectReference(EncounteredStructProps, EPropertyObjectReferenceType::Any)- включая типы с пользовательскими сериализаторами, которые явно не указывают наличие UObject-ссылок.DestructorLink - свойства, требующие вызова деструктора при уничтожении контейнера. FString, TArray, TMap - у них нетривиальные деструкторы. int32, float - нет. Есть нюанс: свойства, принадлежащие нативным классам (CLASS_Native | CLASS_Intrinsic), сюда не попадают - их покрывает нативный деструктор C++ класса. Исключение - свойства с FinishDestroy, они попадают в DestructorLink всегда.
PostConstructLink - свойства, требующие инициализации после создания объекта: копирование значений из CDO, инициализация Config-свойств. Все свойства, не принадлежащие нативным классам, попадают сюда.
Все четыре списка строятся один раз в Link() и экономят работу подсистемам в runtime: GC обходит только RefLink, деструктор объекта проходит только DestructorLink.
Link() - сборка структуры
Link() вызывается после загрузки или создания структуры. Он выполняет четыре задачи:
Вычисляет смещения - проходит по свойствам, выравнивает, назначает Offset_Internal. Начинает с PropertiesSize родительской структуры (если есть наследование) и наращивает.
Считает размер - PropertiesSize = последнее смещение + размер + padding. Для UScriptStruct с нативным C++ аналогом размер берётся из ICppStructOps (sizeof, alignof самой C++ структуры), а не из суммы свойств - это гарантирует совпадение с compile-time layout.
Строит linked lists - PropertyLink, RefLink, DestructorLink, PostConstructLink. Каждое свойство проверяется на соответствующие критерии и добавляется (или не добавляется) в каждый из списков.
Устанавливает вычисляемые флаги - CPF_IsPlainOldData, CPF_NoDestructor, CPF_ZeroConstructor.
Для нативных классов Link() подтверждает смещения, которые UHT записал через STRUCT_OFFSET. Для Blueprint-классов Link() вычисляет смещения с нуля - Blueprint-свойства не имеют compile-time layout.
Интересная деталь из исходников: Link() может перезапуститься. Если линковка свойства изменила его флаги (например, RF_Transient) или имя, весь цикл повторяется, с лимитом в 64 итерации:
if ((bPropertyIsTransient != Property->HasAllFlags(RF_Transient)) || (PropertyName != Property->GetFName())) { LoopNum++; const int32 MaxLoopLimit = 64; ensure(LoopNum < MaxLoopLimit); break; }
UFunction - особый UStruct
UFunction наследует UStruct, и это не случайно. Параметры функции - это свойства, размещённые в "структуре параметров". В gen.cpp для каждой функции генерируется такая структура:
// AbilityComponent.gen.cpp struct AbilityComponent_eventAddState_Parms { FGameplayTag State; };
UFunction знает размер этого буфера (ParmsSize), а свойства-параметры имеют CPF_Parm во флагах и Offset_Internal относительно начала буфера. При вызове функции через ProcessEvent движок выделяет буфер размером ParmsSize, заполняет параметры через ImportText или копирование, и передаёт указатель на буфер.
// Class.h - UFunction class UFunction : public UStruct { EFunctionFlags FunctionFlags; // FUNC_BlueprintCallable, FUNC_Net, ... uint8 NumParms; // Количество параметров uint16 ParmsSize; // sizeof буфера параметров uint16 ReturnValueOffset; // Смещение return value в буфере uint16 RPCId; // ID для сетевого вызова (FUNC_Net) uint16 RPCResponseId; // ID ответного вызова (FUNC_NetService) FProperty* FirstPropertyToInit; // Первое свойство, требующее инициализации private: FNativeFuncPtr Func; // Указатель на C++ реализацию (exec thunk) };
UHT: что генерируется и как регистрируется
Теперь, когда архитектура ясна, можно разобрать подробнее, что именно UHT генерирует в AbilityComponent.gen.cpp. Сводка того, что во что превращается:
Вы пишете в .h | UHT генерирует в .gen.cpp |
|---|---|
| FEnumParams + FEnumeratorParam[] с именами и значениями каждого варианта |
| FStructParams + FPropertyParamsBase*[] для каждого UPROPERTY + sizeof/alignof + ICppStructOps |
| FFloatPropertyParams с именем, флагами, STRUCT_OFFSET, метаданными |
| Два объекта: FArrayPropertyParams (контейнер) + FStructPropertyParams (inner element) |
| Структура параметров, FFunctionParams с флагами, exec thunk |
| Wrapper с FindFunctionChecked + ProcessEvent, структура параметров, exec thunk |
| RepNotifyFunc = "OnRep_Health", CPF_Net | CPF_RepNotify, ENetFields_Private enum |
| StaticRegisterNatives, GetPrivateStaticClass, ENetFields_Private, конструкторы |
Слой 1: Cross Module References
В начале файла - forward declarations Z_Construct_* функций из других модулей:
COREUOBJECT_API UScriptStruct* Z_Construct_UScriptStruct_FGuid(); ENGINE_API UClass* Z_Construct_UClass_UActorComponent(); GAMEPLAYTAGS_API UScriptStruct* Z_Construct_UScriptStruct_FGameplayTag(); PROTOCOLTERMINATE_API UClass* Z_Construct_UClass_UAbilityComponent();
Каждый тип, на который ссылается UAbilityComponent (базовый класс, типы свойств, типы параметров), должен быть сконструирован раньше или одновременно. Z_Construct_* - это ленивые singleton-функции: при первом вызове создают объект, при последующих возвращают кешированный.
Слой 2: Регистрация enum'ов
Для EAttributeModifierMode:
struct Z_Construct_UEnum_ProtocolTerminate_EAttributeModifierMode_Statics { static constexpr UECodeGen_Private::FEnumeratorParam Enumerators[] = { { "EAttributeModifierMode::Add", (int64)EAttributeModifierMode::Add }, { "EAttributeModifierMode::Multiply", (int64)EAttributeModifierMode::Multiply }, { "EAttributeModifierMode::Override", (int64)EAttributeModifierMode::Override }, }; static const UECodeGen_Private::FEnumParams EnumParams; };
FEnumeratorParam - пара {строковое_имя, числовое_значение}. Это то, что позволяет Blueprint и редактору показывать dropdown с названиями вариантов enum'а.
Слой 3: Регистрация структур
Для FAbilityAttribute - массив PropPointers содержит описания всех свойств:
const FPropertyParamsBase* const PropPointers[] = { &NewProp_CurrentValue, // FFloatPropertyParams &NewProp_BaseMinValue, // FFloatPropertyParams &NewProp_BaseMaxValue, // FFloatPropertyParams &NewProp_MinModifiers_Inner,// FStructPropertyParams (inner для TArray) &NewProp_MinModifiers, // FArrayPropertyParams &NewProp_MaxModifiers_Inner,// FStructPropertyParams (inner для TArray) &NewProp_MaxModifiers, // FArrayPropertyParams };
Inner property для TArray - тот же паттерн, который мы видели в разделе про FProperty.
Далее FStructParams собирает всё вместе:
const FStructParams StructParams = { Z_Construct_UPackage__Script_ProtocolTerminate, // Outer package nullptr, // Super struct &NewStructOps, // ICppStructOps (construct/destruct/copy) "AbilityAttribute", // Имя PropPointers, // Массив свойств UE_ARRAY_COUNT(PropPointers), // Количество sizeof(FAbilityAttribute), // Размер alignof(FAbilityAttribute), // Выравнивание RF_Public|RF_Transient|RF_MarkAsNative, EStructFlags(0x00000001), // STRUCT_NoFlags | HasAtomicCppStructOps METADATA_PARAMS(...) };
sizeof и alignof вычисляются компилятором C++, UHT просто вставляет их в сгенерированный код. При Link() движок проверит, что PropertiesSize совпадает.
Слой 4: Регистрация функций и exec thunks
Для каждой UFUNCTION генерируется:
Структура параметров:
struct AbilityComponent_eventAddRemoveState_Parms { FGameplayTag State; bool bRemove; };
Описание свойств-параметров (те же FPropertyParams, но с флагом CPF_Parm).
FFunctionParams с флагами функции:
// (EFunctionFlags)0x04020405 для AddRemoveState означает: // FUNC_Final | FUNC_Native | FUNC_Public // | FUNC_BlueprintCallable | FUNC_BlueprintAuthorityOnly
Exec thunk - мост между VM и нативным C++:
DEFINE_FUNCTION(UAbilityComponent::execAddRemoveState) { P_GET_STRUCT(FGameplayTag, Z_Param_State); P_GET_UBOOL(Z_Param_bRemove); P_FINISH; P_NATIVE_BEGIN; P_THIS->AddRemoveState(Z_Param_State, Z_Param_bRemove); P_NATIVE_END; }
P_GET_STRUCT, P_GET_UBOOL - макросы, которые снимают параметры со стека Blueprint VM (через Stack.StepCompiledIn). P_FINISH маркирует конец параметров. P_THIS->AddRemoveState(...) - непосред��твенно вызов C++ метода. Если вы читали предыдущую статью про VM - это те самые exec-функции, которые вызываются из dispatch loop по опкоду EX_FinalFunction.
Слой 5: BlueprintNativeEvent
Для BlueprintNativeEvent UHT генерирует wrapper-функцию, которая через рефлексию определяет маршрут вызова. Для CanSprint в AbilityComponent.h:
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category = "Ability|Sprint") bool CanSprint() const; virtual bool CanSprint_Implementation() const;
UHT создаёт:
bool UAbilityComponent::CanSprint() const { UFunction* Func = FindFunctionChecked(NAME_UAbilityComponent_CanSprint); if (!Func->GetOwnerClass()->HasAnyClassFlags(CLASS_Native)) { // Blueprint переопределил функцию → вызов через VM AbilityComponent_eventCanSprint_Parms Parms; const_cast<UAbilityComponent*>(this)->ProcessEvent(Func, &Parms); return !!Parms.ReturnValue; } else { // Нет Blueprint override → прямой вызов C++ return const_cast<UAbilityComponent*>(this)->CanSprint_Implementation(); } }
FindFunctionChecked ищет UFunction по имени - начиная с класса объекта и поднимаясь по цепочке наследования. Если функция найдена на Blueprint-классе (без CLASS_Native), значит Blueprint её переопределил - вызов идёт через ProcessEvent, который запускает VM. Если функция осталась на нативном классе - прямой вызов _Implementation(). Как именно VM исполняет Blueprint-переопределение - разберём в следующем разделе.
Слой 6: Metadata - данные о данных
В gen.cpp встречается #if WITH_METADATA и METADATA_PARAMS(...). Метаданные - это пары ключ-значение, которые UHT извлекает из спецификаторов UPROPERTY(), UFUNCTION() и комментариев.
Как хранятся:
// AbilityComponent.gen.cpp static constexpr UECodeGen_Private::FMetaDataPairParam NewProp_SourceTag_MetaData[] = { { "Category", "Attribute" }, #if !UE_BUILD_SHIPPING { "Comment", "// Optional gameplay tag to classify/source the modifier\n" }, #endif { "ModuleRelativePath", "Public/Core/Player/AbilitySystem/AbilityComponent.h" }, #if !UE_BUILD_SHIPPING { "ToolTip", "Optional gameplay tag to classify/source the modifier" }, #endif };
Comment и ToolTip вырезаются в shipping-билде (#if !UE_BUILD_SHIPPING). Category остаётся - она нужна не только для группировки в Details panel, но и для корректной сериализации. Более того, при упаковке плагина UE не даст собрать пакет, если хотя бы на одном UPROPERTY не указана категория (если вам интересна автоматизация этого процесса - у меня есть тулза для сборки плагинов).
Как используются:
Доступ к метаданным в runtime:
FProperty* Prop = MyClass->FindPropertyByName(TEXT("SourceTag")); // Проверка наличия bool bHasCategory = Prop->HasMetaData(TEXT("Category")); // Получение значения const FString& Category = Prop->GetMetaData(TEXT("Category")); // "Attribute" // Типизированный доступ float ClampMin = Prop->GetFloatMetaData(TEXT("ClampMin"));
Стандартные ключи:
Ключ | Где | Что делает |
|---|---|---|
Category | Property, Function | Категория в Details panel и правом клике BP |
DisplayName | Любой | Отображаемое имя (вместо имени переменной) |
ToolTip | Любой | Подсказка при наведении мыши |
EditCondition | Property | Условие видимости/редактируемости ("bUseThreshold") |
ClampMin, ClampMax | Числовые | Ограничения значений |
UIMin, UIMax | Числовые | Ограничения слайдера в UI |
MetaClass | Object/Class properties | Базовый класс для фильтрации |
AllowedClasses | Object properties | Разрешённые классы |
BlueprintProtected | Function | Вызываемо только из owner class |
Эти метаданные влияют только на редактор и инструментарий. В shipping-билде большинство из них отсутствует - они нужны на этапе разработки, не для исполнения в продакшене.
Слой 7: Финальная регистрация
В конце gen.cpp всё собирается в FRegisterCompiledInInfo:
static constexpr FEnumRegisterCompiledInInfo EnumInfo[] = { { EAttributeModifierMode_StaticEnum, TEXT("EAttributeModifierMode"), ... }, { EDamageEventType_StaticEnum, TEXT("EDamageEventType"), ... }, }; static constexpr FStructRegisterCompiledInInfo ScriptStructInfo[] = { { FAttributeModifier::StaticStruct, ..., TEXT("AttributeModifier"), ... }, { FAbilityAttribute::StaticStruct, ..., TEXT("AbilityAttribute"), ... }, { FDamageContext::StaticStruct, ..., TEXT("DamageContext"), ... }, }; static constexpr FClassRegisterCompiledInInfo ClassInfo[] = { { Z_Construct_UClass_UAbilityComponent, UAbilityComponent::StaticClass, TEXT("UAbilityComponent"), ... }, };
Это static initializer - он выполняется при загрузке модуля, до main(). Регистрирует отложенные конструкторы в глобальной очереди. Позже ProcessNewlyLoadedUObjects() вызовет Z_Construct_* функции, те создадут UClass/UScriptStruct/UEnum объекты и вызовут StaticLink() для каждого.
Порядок инициализации:
Static init (загрузка .dll/.so) → FRegisterCompiledInInfo записывает конструкторы в очередь
Module load → ProcessNewlyLoadedUObjects() обрабатывает очередь
Construct → Z_Construct_UClass_UAbilityComponent() создаёт UClass, регистрирует свойства и функции
Link → StaticLink() вычисляет/проверяет смещения, строит linked lists
Мостик к VM: как байткод использует рефлексию
В байткоде ExecuteUbergraph_HumanAbility:
$1C: Final Function (stack node AbilityComponent::AddState) $2F: literal struct GameplayTag (serialized size: 12) $21: literal name Game.Player.State.Alive $30: EX_EndStructConst $16: EX_EndFunctionParms
Что происходит пошагово:
Dispatch loop читает опкод $1C (EX_FinalFunction) из массива UFunction::Script.
Обработчик достаёт из байткода указатель на UFunction для AddState. Этот указатель был записан компилятором Blueprint на этапе компиляции - он ссылается на объект UFunction, созданный рефлексией при загрузке модуля.
UFunction хранит в поле Func указатель на нативную функцию
UAbilityComponent::execAddState- тот самый exec thunk из gen.cpp.VM вызывает
Func(Context, Stack, RESULT_PARAM)- это сигнатура DEFINE_FUNCTION: объект-контекст, стековый фрейм VM (FFrame), указатель на результат.Exec thunk выполняется:
DEFINE_FUNCTION(UAbilityComponent::execAddState) { P_GET_STRUCT(FGameplayTag, Z_Param_State); // Stack.StepCompiledIn<FStructProperty> P_FINISH; // Маркер конца параметров P_NATIVE_BEGIN; P_THIS->AddState(Z_Param_State); // Обычный вызов C++ метода P_NATIVE_END; }
P_GET_STRUCT внутри вызывает Stack.StepCompiledIn<FStructProperty>() - VM продвигает указатель по байткоду, читает FGameplayTag из стека параметров. Тип параметра (FStructProperty) известен из рефлексии - UFunction хранит свои параметры как FProperty. P_THIS->AddState(...) - обычный вызов C++ метода, никакой рефлексии на этом этапе уже нет.
Полная цепочка: опкод в байткоде → dispatch loop → UFunction (рефлексия) → Func (указатель на exec thunk) → exec thunk снимает параметры со стека → вызов нативного C++.
DEFINE_FUNCTION разворачивается в static void execAddState(UObject* Context, FFrame& Stack, void* const Z_Param__Result). Обычная UFUNCTION никогда не видит FFrame - UHT генерирует thunk-обёртку, которая прячет стек VM за обычными C++ аргументами. Но если пометить функцию как CustomThunk - thunk не генерируется, и вы получаете прямой доступ к стеку VM.
BlueprintNativeEvent: рефлексия как диспетчер
Выше мы видели, как UHT генерирует wrapper для CanSprint. Теперь посмотрим на другую сторону - что именно исполняет VM, когда Blueprint переопределил функцию.
В байткоде HumanAbility::CanSprint - полноценная реализация:
$14: LetBool: CallFunc_HasState_ReturnValue = Final Function (AbilityComponent::HasState) literal name States.Human.Aiming $14: LetBool: CallFunc_Not_PreBool_ReturnValue_1 = Not_PreBool(OwningPawn.bIsCrouched) $14: LetBool: CallFunc_IsDeadOrKnocked_ReturnValue = Local Virtual Script Function IsDeadOrKnocked // ... проверки скорости, направления, dot product $14: LetBool: ReturnValue = CallFunc_BooleanAND_ReturnValue_3
Когда нативный код вызывает CanSprint(), wrapper делает FindFunctionChecked - находит UFunction на Blueprint-классе HumanAbility_C (без CLASS_Native) - и отправляет вызов в ProcessEvent → VM исполняет этот байткод. Так рефлексия замыкает маршрут: генерация wrapper'а (UHT) → диспетчеризация (FindFunctionChecked) → исполнение (VM).
EX_StructMemberContext - рефлексия в реальном времени
В CalculateBoneDamage:
$42: Struct member context Member named DamageMultipiler_7_D386E15443C4D5DC2ED61B9880D1DFAB @ offset 16 Expression to struct: Local variable FST_BoneDamage named CallFunc_Array_Get_Item_1
Опкод EX_StructMemberContext ($42) - VM читает поле структуры по имени и смещению. offset 16 - это FProperty::Offset_Internal для поля DamageMultiplier внутри FST_BoneDamage. Тот самый ContainerPtrToValuePtr, только вызванный изнутри VM.
FlowStack - VM управляет потоком, рефлексия обеспечивает вызовы
В OnStatesChangedEvent видна работа FlowStack (подробно разбирали в статье про VM):
$4C: FlowStack.Push(0x273); // Запоминаем точку возврата $4F: if (!condition) { jump to FlowStack.Pop(); } // Условный выход
Сам FlowStack - механизм VM, не рефлексии. Но каждый вызов внутри этих блоков (HasState, RemoveState, AddState, SetStates) проходит через рефлексию: VM резолвит UFunction, находит exec thunk, передаёт параметры. Без рефлексии VM не знала бы, какую нативную функцию вызвать и как передать ей FGameplayTag.
Репликация через рефлексию
Рефлексия - основа сетевой репликации в UE. Движку нужно знать, какие свойства реплицировать, как их сериализовать и какой callback вызвать при изменении.
Для сравнения: в движках без рефлексии (или с минимальной) репликацию приходится писать вручную. В Source Engine каждое реплицируемое свойство описывается в SendTable/RecvTable руками программиста - с явным указанием типа, смещения и функции-декодера. В custom-серверах на основе ENet или SteamNetworking разработчик сам сериализует каждое поле в битовый поток. UE делает всё это автоматически: вы пишете UPROPERTY(Replicated), а движок через рефлексию знает тип, смещение, способ сериализации и может сравнить значение с предыдущим состоянием.
CPF_Net и CPF_RepNotify
Когда вы пишете:
UPROPERTY(ReplicatedUsing = OnRep_HealthAttribute) FAbilityAttribute HealthAttribute;
UHT устанавливает флаги CPF_Net | CPF_RepNotify и записывает RepNotifyFunc = "OnRep_HealthAttribute". При изменении значения на сервере движок итерирует по реплицируемым свойствам (отфильтрованным по CPF_Net), сравнивает текущее значение с предыдущим через FProperty::Identical(), сериализует дельту и отправляет клиенту.
На клиенте при применении дельты проверяется CPF_RepNotify - если установлен, вызывается функция с именем из RepNotifyFunc.
RepIndex и ENetFields_Private
В AbilityComponent.generated.h UHT генерирует enum для сетевых полей:
enum class ENetFields_Private : uint16 { NETFIELD_REP_START = (uint16)((int32)Super::ENetFields_Private::NETFIELD_REP_END + (int32)1), HealthAttribute = NETFIELD_REP_START, StaminaAttribute, States, DamageEffectHandlers, bIsSprinting, LastReceivedDamage, LastStaminaDrainTime, NETFIELD_REP_END = LastStaminaDrainTime };
Каждому реплицируемому свойству назначается RepIndex - его порядковый номер среди всех реплицируемых свойств в иерархии класса. Этот индекс используется для компактного кодирования: вместо имени свойства по сети передаётся 16-битный индекс.
В gen.cpp ValidateGeneratedRepEnums - runtime-проверка, что порядок свойств совпадает с тем, что сгенерировал UHT:
void UAbilityComponent::ValidateGeneratedRepEnums( const TArray<struct FRepRecord>& ClassReps) const { const bool bIsValid = true && Name_HealthAttribute == ClassReps[(int32)ENetFields_Private::HealthAttribute] .Property->GetFName() && Name_StaminaAttribute == ClassReps[(int32)ENetFields_Private::StaminaAttribute] .Property->GetFName() // ... checkf(bIsValid, TEXT("UHT Generated Rep Indices do not match runtime...")); }
Push Model и MarkPropertyDirty
В байткоде Blueprint-наследника HumanAbility видно, как Push Model работает на практике. Функция SetKnockdownCounterInternal:
$F: Let (Variable = Expression) Variable: Instance variable KnockdownCounterInternal Expression: Local variable NewValue (Parameter) $68: Call Math (NetPushModelHelpers::MarkPropertyDirtyFromRepIndex) $17: EX_Self $1D: literal int32 10 $21: literal name KnockdownCounterInternal
MarkPropertyDirtyFromRepIndex с индексом 10 - это Push Model репликация. Вместо того чтобы движок каждый тик сравнивал все реплицируемые свойства, класс сам сообщает: "свойство с RepIndex=10 изменилось". Рефлексия предоставляет имя и индекс, Push Model использует их для оптимизации.
Практика: TFieldIterator, ExportText, ProcessEvent
Итерация по свойствам
// Все свойства класса (включая унаследованные) for (TFieldIterator<FProperty> It(MyClass); It; ++It) { FProperty* Prop = *It; UE_LOG(LogTemp, Log, TEXT("%s: %s @ offset %d"), *Prop->GetName(), *Prop->GetCPPType(), Prop->GetOffset_ForDebug()); } // Только свои свойства (без унаследованных) for (TFieldIterator<FProperty> It(MyClass, EFieldIteratorFlags::ExcludeSuper); It; ++It) { // ... } // Только BlueprintCallable функции for (TFieldIterator<UFunction> It(MyClass); It; ++It) { if (It->HasAnyFunctionFlags(FUNC_BlueprintCallable)) { UE_LOG(LogTemp, Log, TEXT("BP-callable: %s"), *It->GetName()); } }
TFieldIterator под капотом идёт по PropertyLink (для FProperty) или по Children (для UFunction). Флаг ExcludeSuper пропускает свойства, владелец которых - parent struct.
ExportText / ImportText - универсальная текстовая сериализация
Каждый FProperty умеет сериализовать своё значение в строку и обратно:
FProperty* Prop = MyClass->FindPropertyByName(TEXT("HealthAttribute")); void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(MyComponent); // Экспорт в строку FString ValueStr; Prop->ExportTextItem_Direct(ValueStr, ValuePtr, nullptr, MyComponent, PPF_None); // ValueStr = "(CurrentValue=75.0,BaseMinValue=0.0,BaseMaxValue=100.0,...)" // Импорт из строки Prop->ImportText_Direct(*ValueStr, ValuePtr, MyComponent, PPF_None);
Формат зависит от типа: int32 → "42", FVector → "X=1.0 Y=2.0 Z=3.0", структура → "(Field1=Val1,Field2=Val2)". Для вложенных структур - рекурсия.
Вызов функции через рефлексию
UFunction* Func = Target->FindFunction(TEXT("AddState")); if (!Func) return; // Выделяем буфер параметров uint8* ParamBuffer = (uint8*)FMemory_Alloca(Func->ParmsSize); FMemory::Memzero(ParamBuffer, Func->ParmsSize); Func->InitializeStruct(ParamBuffer); // Заполняем параметры for (TFieldIterator<FProperty> It(Func); It; ++It) { FProperty* Prop = *It; if (!Prop->HasAnyPropertyFlags(CPF_Parm) || Prop->HasAnyPropertyFlags(CPF_ReturnParm)) continue; void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(ParamBuffer); // Записываем значение... } // Вызываем Target->ProcessEvent(Func, ParamBuffer); // Очищаем Func->DestroyStruct(ParamBuffer);
ProcessEvent - единая точка входа для вызова UFunction. Для нативных функций он вызывает exec thunk напрямую. Для Blueprint-функций запускает VM. Логика маршрутизации - та самая, что мы видели в generated wrapper для CanSprint.
Заключение
Рефлексия - мост между статическим C++ и динамическими системами UE: редактором, Blueprint, сериализацией, репликацией, GC.
Что это даёт на практике
Понимание рефлексии открывает целый класс задач, которые без неё решаются копипастой или хардкодом:
Динамический доступ к свойствам - можно написать универсальный инспектор, дебаггер или систему сохранений, которая работает с любым UObject без знания его конкретного типа. TFieldIterator + ExportText = дамп любого объекта в строку.
Вызов функций по имени - data-driven системы, где поведение задаётся в таблицах или конфигах. "При событии X вызвать функцию Y с параметрами Z" - всё через FindFunction + ProcessEvent.
Кастомные редакторские тулзы - метаданные (EditCondition, ClampMin, кастомные ключи) позволяют строить сложные UI без единой строчки Slate-кода.
Кастомные системы репликации - зная CPF_Net, RepIndex и FProperty::Identical, можно построить свой слой синхронизации поверх стандартного или параллельно ему.
Плагины с generic-логикой - как FunctionHandler из предыдущей статьи, который хранит вызов произвольной функции как сериализуемые данные, не зная заранее ни типа объекта, ни сигнатуры функции.
Что стоит запомнить
FField/FProperty - лёгкие объекты для свойств, не участвующие в GC. Миграция с UProperty произошла в UE 4.25 ради производительности.
Offset_Internal - смещение свойства от начала контейнера. Ключ к ContainerPtrToValuePtr - прямому доступу к значению через арифметику указателей.
UStruct - контейнер свойств. Четыре linked list для разных подсистем: итерация, GC, деструкция, post-construct.
Link() - сборка структуры: вычисление смещений, построение linked lists, установка вычисляемых флагов.
UHT генерирует enum'ы, структуры, функции, exec thunks, метаданные и финальную регистрацию. Для UAbilityComponent это 4294 строки кода.
Exec thunk - мост VM → натив. P_GET_* снимает параметры со стека VM, вызывает C++ метод.
ExportText/ImportText - универсальная текстовая сериализация любого свойства.
ProcessEvent - единая точка входа для вызова функций через рефлексию.
Этой статьёй цикл по внутренностям Blueprint замыкается: K2Node → VM → рефлексия. Три слоя одной системы, от визуальных нод до арифметики указателей. Цикл завершён, но в движке еще много чего осталось - будем изучать в следующих статьях.
