The Mental Model

Everything in SKSE reverse engineering reduces to one question: “where is the thing I want, and how do I talk to it?” There are four kinds of “address” and one verification habit. Learn those and the rest is tooling.

1. A building with no street signs

SkyrimSE.exe is a compiled binary. Every function and data structure is in there, but the names were stripped at compile time. So each function is just an address, like an apartment with no nameplate, only a unit number measured from the front door.

All of RE is about putting nameplates back on, and then knocking on the right door. When Bethesda ships a new version (SE 1.5.97AE 1.6.1170) they recompile, and every apartment moves. The same tenant now lives at a different unit number. That single fact is the source of almost every dual-build bug.

2. The four kinds of “address”

These are the entire vocabulary. Click nothing here, just absorb the four.

① RVA — Relative Virtual Address raw, version-specific

The literal distance of a function from the start of the binary. 0xFAC020 = “0xFAC020 bytes in.” A street address that is just a distance from the door.

The catch: recompiling shifts everything. ObjectInterface::Invoke is 0xFAC020 on AE but 0xECA860 on SE. Hardcode a raw RVA and your plugin works on exactly one game version.

② Address Library ID — the RelocationID number stable across versions

The community maintains a database (the versionlib .bin files) mapping a logical function to its unit number per version: “UpdateHoveredMarker is 52224 on SE and 53111 on AE.” The ID is the nameplate the building never had.

REL::ID(n) = “look up unit n in the directory for the version currently running.” It hands back the right RVA automatically.

This is exactly where our SE crash came from. The plugin called REL::ID(53111). But 53111 is the AE directory’s number. On SE, entry 53111 is a different apartment — a save/load function. Same number, different building, different tenant. Fix: REL::RelocationID(52224, 53111) = “the same logical tenant, addressed correctly in each building.”

③ Struct field offset — object + 0xNNN data, not code

Functions are one thing; the data they touch is another. A live MapMenu object is a big struct, and the fields you want sit at fixed byte offsets inside it. MapMenu + 0x30558 = “the hovered-marker index lives 0x30558 bytes into the object.”

These can also shift between versions if Bethesda adds or reorders fields. In our port, 3 of 4 MapMenu offsets were identical SE vs AE; only the array count moved: +0x30478+0x30480. We gated just that one with REL::Relocate(0x30480, 0x30478).

④ Vtable slot — CALL [RAX+0xE0] indirect, type-dependent

A virtual function isn’t called by a fixed address. The object carries a little table of function pointers (its vtable), and the code says “call whatever sits in slot 0xE0 of this object’s table.” The actual target depends on the object’s real runtime type.

The catch: the slot number is part of the class layout, which can shift between versions and between CommonLib-NG’s idea of the layout and the real binary. NG vtables have been seen off-by-one vs the engine. When you read CALL [RAX+0xE0] in a decompile, that’s a vtable dispatch — to know what it calls you must know the object’s type and walk its vtable in the actual binary, not trust the header.

3. How they chain together

One hook is a walk through all four. Click each step to expand. This is the real chain for the hook that crashed.

STEP 1
Want a function
UpdateHoveredMarker
STEP 2
Address Library ID
52224 / 53111
STEP 3
→ RVA → detour
0x8E6140 / 0x986360
STEP 4
Read struct offset
menu+0x30558
STEP 5
Vtable call into Flash
CALL [RAX+0xE0]
Want a function. We want our code to run whenever the engine updates which vanilla map marker is under the cursor. That engine function has no name in the binary — only an address that differs per version. So we can’t hardcode it; we address it by its stable ID.
Address Library ID. The logical function is SE 52224 / AE 53111. We write REL::RelocationID(52224, 53111) — SE first, AE second (confirmed from CommonLib’s Relocation.h ctor order). At load, CommonLib picks the right one for the running runtime.
ID → RVA → detour. The library converts the ID to the real memory address (0x8E6140 on SE). PolyHook then installs a detour: it overwrites the first few bytes of that function with a jump to our function, and copies the displaced bytes into a trampoline so we can still call the original. Now every time the engine runs UpdateHoveredMarker, ours runs too.
Read struct offset. Our hook receives a pointer to the live MapMenu object. To learn which marker is hovered we read menu + 0x30558 as an int32. To walk the marker array we read the data ptr at +0x30470 and the count at +0x30478/+0x30480. These are data offsets, a different addressing kind than the function id.
Vtable call into Flash. To make the engine hide the description card, the path goes through a Scaleform “Invoke” bridge (FxDelegate::Invoke / ObjectInterface::Invoke), which we also address by ID. Internally that bridge does a vtable dispatch (CALL [RAX+0xE0]) to cross from C++ into the Flash/AS2 UI. That’s the C++→Flash boundary.

The one-line summary: ID → RVA → detour → struct offset → vtable call. Every hook is some path through that. The “Invoke” functions are just the engine’s bridges from C++ into the Flash UI, addressed by ID, then dispatching by vtable.

4. Failure modes — why bugs hide

The thread through all four kinds: none of these errors throw a clean “not found.” A wrong id, wrong offset, or wrong slot returns plausible-looking garbage that crashes later, somewhere else. That is why static decompiles are never enough.

KindYou search forWhat you can silently get wrongHow it’s caught
① RVAoffset in .exe Correct for exactly one version. Any bare RVA is a latent crash on the other build. Replace with an ID pair; grep for stray raw RVAs / bare REL::ID.
② Address Library IDSE/AE id pair Using one version’s id on both. It resolves to something on the other version, so no error — just the wrong function. (Our crash.) Read the resolved function and confirm it references the offsets/strings the real one should.
③ Struct offset+0xNNN field Assuming an offset matches across versions when it shifted. The read returns a number, just garbage. Live-dump the field; sanity-check the value (small index? readable ptr? plausible count?).
④ Vtable slot[RAX+0xN] index Off-by-one between CommonLib-NG and the real binary, or the object isn’t the type you assumed → wrong virtual called. Verify the slot in the actual binary, not the header.
The trap that connects them all: wrong id, wrong offset, wrong slot — all return data that looks valid and crash elsewhere. The decompile tells you where to look; only the running game tells you if you got it right.

5. Worked example — our actual SE crash

The whole port reduced to spotting this in a crash log. Here is the trace from symptom to fix.

The crash

; access violation, rcx = 0x28 (near-null)
SkyrimSE.exe+0ECA173  mov rdx, [rcx]          ; deref a near-null GFxValue
SKSE_CustomMapMarkers.dll  GFxValue::GetMember("MarkerDescriptionObj")
SKSE_CustomMapMarkers.dll  HoverImageRenderer::SampleCardState(MapMenu*)
SkyrimSE.exe  ... 53115 / 53204   ; these are SAVE-LOAD / VM funcs!
; RSP objects: BGSSaveLoadManager, LoadRequest, "VM is freezing..."

What the probe revealed

A one-shot logger printed the MapMenu* our hook received:

[SE-PROBE] MapMenu base =
  0x7ff66d747e80

That address is inside SkyrimSE.exe’s static data and matches the SkyrimVM global all over the crash stack. So a_menu wasn’t a MapMenu at all — it was the SkyrimVM.

The diagnosis

The hook installed on REL::ID(53111). On SE, id 53111 is a save/load/VM function, not UpdateHoveredMarker. So our hook fired during loading, was handed the SkyrimVM, and SampleCardState read SkyrimVM + 0x30540 as if it were a GFxValue → near-null deref → crash.

The fix

// before — AE id used on both builds
REL::Relocation<std::uintptr_t> target{ REL::ID(53111) };

// after — same logical fn, addressed per version
REL::Relocation<std::uintptr_t> target{ REL::RelocationID(52224, 53111) };
Note the deeper lesson: the offset +0x30540 was actually correct on both versions. The first instinct (“an offset is wrong”) was wrong. The bug was one level up — we were reading a correct offset on the wrong object, because the id resolved to the wrong function. Always ask which addressing kind is actually at fault before assuming it’s the one you touched last.

How each SE twin was then confirmed (not guessed)

For every id, the SE candidate was read and checked against what the real function must contain — the verify habit in action:

HookAE idSE idConfirmed by
UpdateHoveredMarker5311152224references +0x30558, +0x30540, +0x30470, +0x30480
OnMouseDown5311752230checks +0x3058c & +0x30558, calls FxDelegate::Invoke
FxDelegate::Invoke8264080520both SE twins call it at 0x140ed6ac0
IsMarkerVisible5308452192called in the inner marker loop, same position
ObjectInterface::Invoke8225680233fingerprint: 4 args, [RAX+0x98], vtable CALL [RAX+0xE0]

Same-on-both ids do exist (ScreenToWorldRay 70630, Compass 51668/51744) — but you only know that by checking, never by assuming.

6. What a detour actually does, byte by byte

“We hook the function” hides a small surgery. PolyHook overwrites the first few bytes of the target with a jump to our code, and saves the bytes it clobbered into a trampoline so the original can still run. Step through it.

target function
0x8E6140
trampoline
(heap)

Why a trampoline exists: writing the jump destroys the first instructions of the original. The trampoline holds a working copy of those bytes plus a jump back into the rest of the function, so our hook can still run the real engine code (this is how every g_trampoline(...) call in our hooks works). Why the first bytes only: a 64-bit jump needs ~12–14 bytes; PolyHook disassembles whole instructions so it never cuts one in half (that’s what the “Capstone disassembler” in each Install() is for).

7. The C++ → Flash bridge (the “Invoke” functions)

The map UI is a Flash movie (Scaleform). Our hooks live in C++. The two Invoke functions are the doorways between those worlds — that’s all they are. Click each layer to see what it’s for.

C++ side (the engine / our plugin)

Our detour
on UpdateHoveredMarker / Invoke
FxDelegate::Invoke 8052082640
native→Flash, by method name
ObjectInterface::Invoke 8023382256
lower-level AS2 object call
vtable dispatch →

Flash side (the .swf UI movie)

method name string
"SetSelectedMarker" · "MarkerDescriptionObj"
AS2 function on a clip
runs inside the movie
card shows / hides
the visible result
Click any layer to see its role.

The key insight: the bridge is crossed by method name, a string, not a function address. That’s why "SetSelectedMarker" is itself an Address Library entry (270681216427): we resolve the string’s address so our detour can pointer-compare the name being invoked and act only on the one we care about. The crossing itself is a vtable dispatch (CALL [RAX+0xE0]) — addressing kind ④ from section 2, doing real work here.

8. The verify-live checklist

Keep this open while doing RE. For every hook, after resolving the id, ask: does the function / field / slot this resolves to actually look like what I expect, on this specific version?

The whole game in one sentence: there are four ways to address a thing (RVA, ID, struct offset, vtable slot); each fails silently by returning plausible garbage; the static decompile shows you where to look and the running game tells you if you were right.