Skip to content

Hook Engine

The hook engine lets Rust (and therefore Luau) run code inside engine functions. The design rule: never modify engine code bytes — only vtable pointers and our own allocated memory.

A Source interface object starts with a vptr to its class vtable. To hook virtual method N of object O:

1. allocate one RWX block: [ copy of vtable | stub area ]
2. copy all vtable entries into the copy
3. overwrite copy[N] with a stub:
pushad ; save every register (incl. ecx=this)
call <Rust target> ; cdecl, no args touched on the real stack
popad ; restore
jmp <original N> ; tail-jump; ret/args behave normally
4. write copy's address into O's vptr field

The engine calls through the copy, our target observes, the original runs with identical register/stack state. Restore = write the original vptr back. The vptr write is an aligned 4-byte store — atomic on x86, so a call racing the swap sees either the old or new table, never a hybrid.

Before naming any hook, we measure: usems_hook_profile [seconds] installs a counting variant of the stub on every client-interface vtable slot and reports call frequencies.

game console:
usems_hook_profile 6
log:
[USEMS] hook: slot 35 called 5970 times <- 3x/frame (dispatch query)
[USEMS] hook: slot 55 called 1991 times <- frame-rate set
[USEMS] hook: slot 11 called 1990 times <- frame-rate set
...

Reading the output:

Signature in counts Meaning
N slots at identical frame-rate counts per-frame path (HudUpdate family)
~3× frame rate multi-caller frame function (e.g. engine ReturnValue(int) dispatch)
~60/s steady think-tick cadence
exactly 1 one-shot init/shutdown

The profiler found the frame-rate slots; disassembly named them. Slot 11 (client.dll RVA 0xf92f0):

mov eax, [0x104ab294] ; load a global pointer <-- candidate gpGlobals
fld dword [eax+0x10] ; read float at +0x10 <-- frametime
lock (0xa0740) ; HudElement lock
push [ebp+8] ; use the bool argument <-- bActive

The gpGlobals + frametime + bActive signature identified HudUpdate and handed us gpGlobals’ address as a bonus — that’s where the frame hook’s dt comes from.

Finding GameFrame — second worked example

Section titled “Finding GameFrame — second worked example”

Same recipe on the server interface. ServerGameDLL009’s CreateFn is mov eax, 0x10676e68; ret → singleton at server.dll RVA 0x676e68 (vtable 0x52da50). The profiler over 10 in-game seconds:

[USEMS] hook: slot 5 called 541 times <- 66/s: the tick rate
[USEMS] hook: slot 25 called 541 times <- 66/s: query called by the same loop
[USEMS] hook: slot 6 called 288 times <- ~35/s: half-rate path
[USEMS] hook: slot 31 called 288 times

Disambiguating the two 66/s slots:

slot 25 (0x18a4c0): mov al, [0x10700cc5] / ret
-- a one-byte getter (IsPaused) — called BY the tick loop, not the tick
slot 5 (0x189bd0): cmp byte [0x10700cc5], 0 ; paused check (calls slot 25's global!)
call 0x10117280 ; game systems work
test byte [eax+0x18], 1 ; state flag branching
ret 4 ; exactly one bool arg
-- GameFrame(bool simulating): the real per-tick worker

Slot 5 it is — cadence and signature both check out.

hooks::install_frame_hook() -> Result<(), String>

Section titled “hooks::install_frame_hook() -> Result<(), String>”

Builds the vtable copy with the HudUpdate stub and swaps the vptr. Installs once at boot, after mods load. Refuses if a client profile run owns the swap, or on games without verified caps.

hooks::install_frame_hook()?;
// [USEMS] hook: frame hook installed (client.dll slot 11)

hooks::install_tick_hook() -> Result<(), String>

Section titled “hooks::install_tick_hook() -> Result<(), String>”

Server twin: interposes GameFrame (slot 5) on the ServerGameDLL009 singleton. Independent of the frame hook — different object, different vptr swap; only same-interface operations conflict.

hooks::install_tick_hook()?;
// [USEMS] hook: tick hook installed (server.dll slot 5)

hooks::profile_iface(iface, seconds) -> Result<(), String>

Section titled “hooks::profile_iface(iface, seconds) -> Result<(), String>”

Installs counting stubs on all slots of Iface::Client or Iface::Server, restores after seconds (1–60), then logs the frequency table. Blocked only when the same interface’s hook owns its vptr swap.

hooks::profile_iface(hooks::Iface::Server, 10)?;

Manual restore if you need the vptr back before the timer fires.

hooks::iface_vtable(iface) -> Option<(object, vtable, slots)>

Section titled “hooks::iface_vtable(iface) -> Option<(object, vtable, slots)>”

Resolves an interface object, its vtable, and the slot count (validated: every entry must point inside the interface’s own module). Also logs a one-time slot→RVA map for offline correlation.

hooks::frame_count() / tick_count() / frame_hook_active() / tick_hook_active()

Section titled “hooks::frame_count() / tick_count() / frame_hook_active() / tick_hook_active()”

Diagnostics: total invocations and install state for both hooks.

engine thread, every frame
└─ HudUpdate(bActive) [client.dll, slot 11]
└─ stub: pushad → call → popad → jmp original
└─ hooks::frame_hook_target() [Rust, cdecl]
├─ FRAME_COUNT += 1
├─ dt = *gpGlobals.frametime
└─ mod_runtime::dispatch_frame_hooks(dt)
└─ your Luau callback(dt) [budget-checked per mod]
server thread, 66/s
└─ GameFrame(bool simulating) [server.dll, slot 5]
└─ stub → hooks::tick_hook_target()
├─ TICK_COUNT += 1
└─ mod_runtime::dispatch_tick_hooks()
└─ your Luau callback() [budget-checked per mod]

Both Rust targets are deliberately tiny: an atomic increment, at most one pointer read, and the dispatch loop. Everything expensive (console I/O, error formatting) happens only on the failure path, once.

Hand-assembled, in source_console_bridge.cpp:

usems_hook_emit_count_stub(slot, original, counter, out, capacity)

Section titled “usems_hook_emit_count_stub(slot, original, counter, out, capacity)”

22 bytes: pushad; push slot; call counter; add esp,4; popad; jmp rel32. Counting target is the Rust usems_hook_count(slot).

usems_hook_emit_call_stub(target, original, out, capacity)

Section titled “usems_hook_emit_call_stub(target, original, out, capacity)”

13 bytes: pushad; call target; popad; jmp rel32 — the frame-hook shape (no arguments marshalled; facts are read inside the Rust target).

New virtual hooks are three steps: profile to find the slot, disassemble to confirm the signature, then register a stub in the install routine. Non-virtual internals (e.g. entity TakeDamage) need inline detours (patch first bytes + trampoline) — planned with per-build pattern offsets in caps, the one technique that does touch code bytes.