Это завершающая часть трилогии о внутренностях 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() вызывается после загрузки или создания структуры. Он выполняет четыре задачи:

  1. Вычисляет смещения - проходит по свойствам, выравнивает, назначает Offset_Internal. Начинает с PropertiesSize родительской структуры (если есть наследование) и наращивает.

  2. Считает размер - PropertiesSize = последнее смещение + размер + padding. Для UScriptStruct с нативным C++ аналогом размер берётся из ICppStructOps (sizeof, alignof самой C++ структуры), а не из суммы свойств - это гарантирует совпадение с compile-time layout.

  3. Строит linked lists - PropertyLink, RefLink, DestructorLink, PostConstructLink. Каждое свойство проверяется на соответствующие критерии и добавляется (или не добавляется) в каждый из списков.

  4. Устанавливает вычисляемые флаги - 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

UENUM(BlueprintType) enum class EAttributeModifierMode

FEnumParams + FEnumeratorParam[] с именами и значениями каждого варианта

USTRUCT(BlueprintType) struct FAbilityAttribute

FStructParams + FPropertyParamsBase*[] для каждого UPROPERTY + sizeof/alignof + ICppStructOps

UPROPERTY(EditAnywhere) float CurrentValue

FFloatPropertyParams с именем, флагами, STRUCT_OFFSET, метаданными

UPROPERTY() TArray<FAttributeModifier> MinModifiers

Два объекта: FArrayPropertyParams (контейнер) + FStructPropertyParams (inner element)

UFUNCTION(BlueprintCallable) void AddState(...)

Структура параметров, FFunctionParams с флагами, exec thunk

UFUNCTION(BlueprintNativeEvent) bool CanSprint()

Wrapper с FindFunctionChecked + ProcessEvent, структура параметров, exec thunk

UPROPERTY(ReplicatedUsing = OnRep_Health)

RepNotifyFunc = "OnRep_Health", CPF_Net | CPF_RepNotify, ENetFields_Private enum

GENERATED_BODY() в классе

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() для каждого.

Порядок инициализации:

  1. Static init (загрузка .dll/.so) → FRegisterCompiledInInfo записывает конструкторы в очередь

  2. Module load → ProcessNewlyLoadedUObjects() обрабатывает очередь

  3. Construct → Z_Construct_UClass_UAbilityComponent() создаёт UClass, регистрирует свойства и функции

  4. 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

Что происходит пошагово:

  1. Dispatch loop читает опкод $1C (EX_FinalFunction) из массива UFunction::Script.

  2. Обработчик достаёт из байткода указатель на UFunction для AddState. Этот указатель был записан компилятором Blueprint на этапе компиляции - он ссылается на объект UFunction, созданный рефлексией при загрузке модуля.

  3. UFunction хранит в поле Func указатель на нативную функцию UAbilityComponent::execAddState - тот самый exec thunk из gen.cpp.

  4. VM вызывает Func(Context, Stack, RESULT_PARAM) - это сигнатура DEFINE_FUNCTION: объект-контекст, стековый фрейм VM (FFrame), указатель на результат.

  5. 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 → рефлексия. Три слоя одной системы, от визуальных нод до арифметики указателей. Цикл завершён, но в движке еще много чего осталось - будем изучать в следующих статьях.