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.
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.97 → AE 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.
These are the entire vocabulary. Click nothing here, just absorb the four.
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.
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.
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.”object + 0xNNN data, not codeFunctions 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).
CALL [RAX+0xE0] indirect, type-dependentA 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.
One hook is a walk through all four. Click each step to expand. This is the real chain for the hook that crashed.
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.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.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.
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.
| Kind | You search for | What you can silently get wrong | How it’s caught |
|---|---|---|---|
| ① RVA | offset 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 ID | SE/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 whole port reduced to spotting this in a crash log. Here is the trace from symptom to fix.
; 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..."
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 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.
// 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) };
+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.For every id, the SE candidate was read and checked against what the real function must contain — the verify habit in action:
| Hook | AE id | SE id | Confirmed by |
|---|---|---|---|
| UpdateHoveredMarker | 53111 | 52224 | references +0x30558, +0x30540, +0x30470, +0x30480 |
| OnMouseDown | 53117 | 52230 | checks +0x3058c & +0x30558, calls FxDelegate::Invoke |
| FxDelegate::Invoke | 82640 | 80520 | both SE twins call it at 0x140ed6ac0 |
| IsMarkerVisible | 53084 | 52192 | called in the inner marker loop, same position |
| ObjectInterface::Invoke | 82256 | 80233 | fingerprint: 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.
“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.
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).
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.
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.
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?
+0x30558 and call IsMarkerVisible?)REL::ID as a latent other-version crash. Convert to a RelocationID pair.