Skip to content

UE4 Implementation

Detailed documentation of the Unreal Engine 4.27 engine adapter — vtable hooking, memory scanning, FName resolution, and ProcessEvent interception.


Overview

The UE4 adapter is the production engine adapter for QS-Bridge. It hooks into UE4 4.27 game servers via LD_PRELOAD, intercepting virtual function calls to capture game events and state changes.

Source Files

File Lines Purpose
lib/engines/ue4/entry.cpp ~200 __attribute__((constructor)) bootstrap, init thread
lib/engines/ue4/introspection.cpp ~300 Memory scanning, UE4 reflection (FProperty walks)
lib/engines/ue4/vtable_hook.cpp ~250 vtable-based function hooking with mprotect
lib/engines/ue4/fname_cache.cpp ~150 FName string resolution and caching
include/qsbridge/engines/ue4_types.h ~300 UE4 memory layout structs
include/qsbridge/engines/introspection.h ~100 Reflection utilities
include/qsbridge/engines/vtable_hook.h ~80 Hook manager interface

Bootstrap Sequence

// entry.cpp — LD_PRELOAD constructor
__attribute__((constructor))
void qs_bridge_init() {
    // 1. Parse QSB_* environment variables
    // 2. Initialise logging
    // 3. Spawn init thread (avoids blocking game startup)
    std::thread(init_thread_func).detach();
}

void init_thread_func() {
    // Wait for UE4 to initialise (GEngine pointer becomes non-null)
    while (!GEngine) { usleep(100'000); }

    // 4. Create UE4 adapter
    auto adapter = std::make_unique<UE4Adapter>();
    adapter->init();

    // 5. Create STDB connection
    auto conn = std::make_unique<StdbConnection>(stdb_host, module_name);

    // 6. Create game module (e.g., HumanitZ)
    auto game = GameFactory::create(game_module_name);
    game->on_init(conn.get());
    game->register_hooks(adapter.get());

    // 7. Connect to SpacetimeDB
    conn->connect();

    // 8. Enter tick loop (or hook into engine tick)
}

vtable Hooking

How UE4 vtables Work

Every UE4 object that inherits from UObject has a vtable pointer at offset 0. The vtable is an array of function pointers. By replacing entries in this array, we intercept method calls.

graph LR
    subgraph UObject Instance
        VP[vtable_ptr]
        UF[UObject fields]
        ETC1[...]
    end
    subgraph vtable
        F0["func_ptr[0] — ~UObject()"]
        F1["func_ptr[1] — ProcessEvent ⬅ WE HOOK THIS"]
        F2["func_ptr[2]"]
        ETC2[...]
    end
    VP --> F0

ProcessEvent Hook

The most important hook — UObject::ProcessEvent is called for every UE4 RPC, event, and blueprint function:

// Original signature
void UObject::ProcessEvent(UFunction* Function, void* Params);

// Our hook
void hooked_ProcessEvent(UObject* self, UFunction* function, void* params) {
    // 1. Get function name
    const char* name = fname_cache.resolve(function->name_index);

    // 2. Check if game module cares about this RPC
    if (rpc_dispatcher.has_handler(name)) {
        rpc_dispatcher.dispatch(name, params, self);
    }

    // 3. Call original ProcessEvent (game continues normally)
    original_ProcessEvent(self, function, params);
}

Memory Protection

vtable pages are typically read-only. We use mprotect to temporarily make them writable:

void* page = (void*)((uintptr_t)vtable & ~(PAGE_SIZE - 1));
mprotect(page, PAGE_SIZE, PROT_READ | PROT_WRITE);  // Make writable
vtable[index] = hook_function;                        // Replace
mprotect(page, PAGE_SIZE, PROT_READ);                // Restore

FName Resolution

UE4 uses FName objects (integer indices into a global name table) instead of strings for performance. The bridge needs to convert these to strings:

class FNameCache {
    std::unordered_map<uint32_t, std::string> cache_;
    FNameEntry** name_table_;  // Pointer to UE4's GNames table

public:
    const char* resolve(uint32_t index) {
        auto it = cache_.find(index);
        if (it != cache_.end()) return it->second.c_str();

        // Read from UE4's name table
        FNameEntry* entry = name_table_[index >> 16][index & 0xFFFF];
        std::string name = read_fname_entry(entry);
        cache_[index] = name;
        return cache_[index].c_str();
    }
};

UE4 Memory Layout

// include/qsbridge/engines/ue4_types.h

// UE4 vector (12 bytes)
struct FVector {
    float X, Y, Z;
};

// UE4 rotator (12 bytes)
struct FRotator {
    float Pitch, Yaw, Roll;
};

// UE4 string (16 bytes on x64)
struct FString {
    TCHAR* Data;
    int32_t Count;
    int32_t Max;
};

// UE4 array (16 bytes on x64)
template<typename T>
struct TArray {
    T* Data;
    int32_t Count;
    int32_t Max;
};

// UE4 name (8 bytes)
struct FName {
    uint32_t ComparisonIndex;
    uint32_t Number;
};

Introspection

The introspection module scans UE4's reflection system to find property offsets at runtime:

// Find the byte offset of a property within a UStruct
int32_t find_property_offset(UStruct* struct_class, const char* property_name) {
    for (FProperty* prop = struct_class->PropertyLink; prop; prop = prop->NextProperty) {
        if (strcmp(fname_cache.resolve(prop->NameIndex), property_name) == 0) {
            return prop->Offset;
        }
    }
    return -1;  // Property not found
}

This allows the bridge to read game state without hardcoded memory offsets (which break when the game updates).