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).
Related Pages¶
- Bridge Internals — framework overview
- Engine Adapters — adapter architecture
- Memory Offsets — memory layout tables
- RPC Parameter Structs — RPC type definitions