| name | dreadnought-hooks |
| description | How to hook a native UFunction in the Dreadnought client without corrupting UObjects. Use before adding or reviewing any MinHook target - a UE4 native function exists as an exec thunk plus a real body with completely different signatures, and hooking the wrong one writes through a UObject header and silently breaks the Blueprint VM. Covers finding the real body, choosing a signature, and the symptoms of getting it wrong. |
Hooking native UFunctions safely
Every native UFunction in DreadGame exists as two functions with
different calling conventions. Hooking the wrong one does not fail loudly —
it corrupts objects and the damage surfaces somewhere else entirely.
Note for the server side. This one is genuinely client-mod specific and you
will probably never need it. It is here for completeness, and because the
thunk/body split is worth recognising if you ever read client code that looks
like it takes an FFrame* where you expected a parameter.
Read this before adding a MinHook target. Use the dreadnought-rva skill to
find and verify the addresses.
The two forms
The exec thunk — autogenerated, always the same 141-byte shape:
void exec(UObject* Context , FFrame& Stack , void* RESULT_DECL ) {
...read params out of the FFrame...
*(Stack + 0x20) += (*(Stack + 0x20) != 0);
result = RealBody(Context, param);
*RESULT_DECL = result;
}
The real body — an ordinary C++ method, typically
(void* pThis /*RCX*/, int32_t itemID /*EDX*/) returning in RAX.
Registrars call RegisterNativeFunc(class, "Name", execThunk), so an address
you found by name is the thunk, not the body.
Why hooking the thunk with a member signature is destructive
Declare a hook as (A* first, B second) and the compiler binds first to RCX
and second to RDX. On a thunk that means:
first is the Context UObject, not your object. Writing through it lands
on the UObject header — +0x00 vtable, +0x08 ObjectFlags, +0x0C
InternalIndex.
second is an FFrame*, not a parameter. Any log line printing it as an
integer is printing a pointer.
RESULT_DECL is dropped, so the function returns uninitialised memory.
- Skipping the thunk body leaves
FFrame::Code un-advanced past
EX_EndFunctionParms, corrupting the Blueprint VM.
This froze the Owned Ships screen: the hook overwrote the widget's vtable
pointer and the game died on the next virtual call — nowhere near the hook.
Symptoms that should make you suspect this: a crash on a virtual call in an
object you never touched; a UObject whose vtable looks like a small integer;
Blueprint execution running past the end of a function; a hook whose "id"
parameter is always a huge pointer-shaped number.
Finding the real body
Decompile the thunk and take the call it makes. Do not guess, and do not stop at
the first hop — one of these has three levels.
Verified pairs (all thunk RVAs confirmed as .pdata entries; the four 141-byte
ones are the canonical shape):
| UFunction | Thunk | Real body |
|---|
UI_OwnedShipsScreen.GetOwnedShipDataStructs | 0xBB9530 | 0xACB760 |
UI_OwnedShipsScreen.GetOwnedShipLoadouts | 0xBB95E0 | 0xACBA70 |
YCtAInventoryInterface.HasItem | 0x75C430 | 0x306C80 |
YCtATechTreeInterface.CanResearchItem | 0x743E50 | 0x31E880 |
YCtATechTreeInterface.GetItemState | 0x752D80 | 0x543BE0 (see below) |
YCtATechTreeInterface.CanPurchaseItem | 0x743DC0 | not yet resolved |
The three-hop case, and why it matters
GetItemState's thunk calls 0x322C30, which was recorded as the real body.
It isn't. pdata.py flags it:
$ python pdata.py 322C30
0x322C30 NO UNWIND RECORD (gap 0x322C2C..0x322C40, 20 bytes) -- padding, data, or a leaf/tail-call stub
The bytes are a 12-byte tail-call stub:
8b c2 mov eax, edx
48 8b d1 mov rdx, rcx
8b c8 mov ecx, eax
e9 a4 0f 22 00 jmp 0x543BE0
0x543BE0-0x543C77 is the real body, with a proper prologue and five other
callers.
The stub swaps its arguments. It receives (rcx = itemID, rdx = pThis) and
calls the body as (rcx = pThis, rdx = itemID). So hooking 0x322C30 with the
body's (void* pThis, int32_t itemID) signature gets them backwards — you
would read an item id as an object pointer. Same class of bug as the thunk
mistake, one level down, and no crash at the hook site.
Hook 0x543BE0. It has the clean signature and is where every other caller
converges.
The two safe approaches
- Hook by name with the exec signature. Use
InstallNativeHook with
(Context, Stack, RESULT_DECL) and call the original first, so P_FINISH
runs and the bytecode pointer advances. Then adjust the result.
- MinHook the real body. Simple register signature, no
FFrame handling,
no VM state to maintain. Preferred when you have the body address.
Checklist before adding a hook