Internals — Open Multiplayer ABI¶
This page documents the C++ ABI details rust-samp implements in pure Rust to interoperate with the Open Multiplayer server. It is meant for contributors to the SDK and for plugin authors who need to call into other server components beyond the high-level wrappers shipped with the SDK.
The information here was derived from the public specification of
the Open Multiplayer SDK
(https://github.com/openmultiplayer/open.mp-sdk) and validated
through runtime + disassembly of omp-server.exe / Console.dll /
Console.so. No SDK code was copied — only the layouts and slot
indices.
Two ABIs¶
Open Multiplayer 32-bit binaries exist on two ABIs:
| Target | ABI | Calling convention | Notes |
|---|---|---|---|
i686-unknown-linux-gnu |
Itanium | extern "C" |
Default GCC behavior on i686 Linux. |
i686-pc-windows-msvc |
MSVC | extern "thiscall" |
this in ECX; cdecl for variadic virtuals. |
i686-pc-windows-gnu |
— | unsupported | The Windows server uses MSVC; do not target this. |
Every vtable struct in samp_sdk::omp::* is gated with
#[cfg(target_env = "msvc")] / #[cfg(not(target_env = "msvc"))] so
each build sees the layout that matches its ABI.
OmpComponent memory layout¶
OmpComponent is the Rust type laid out to be cast directly into the
server's IComponent*. The header inheritance is
IComponent : public IExtensible, public IUIDProvider, which on a
typical Itanium/MSVC i686 layout means two vtable pointers and one
secondary subobject.
Itanium ABI (Linux GCC, i686)¶
offset 0 vtable* (primary: IExtensible + IComponent)
offset 4 _misc_ext[36] (robin_hood::unordered_flat_map, zero-init = empty)
offset 40 uid_vtable* (secondary: IUIDProvider subobject)
offset 44 uid (UID = u64; GCC i686 aligns uint64_t to 4 bytes)
offset 52 plugin_ptr (*mut () — plugin's own field)
total 56 bytes
MSVC ABI (Windows i686)¶
offset 0 vtable* (primary: IExtensible + IComponent)
offset 4 _misc_ext[52] (padding + robin_hood + trailing pad, zero-init)
offset 56 uid_vtable* (secondary: IUIDProvider subobject; offset hardcoded by the server)
offset 60 _uid_pad[4] (alignment padding for uid)
offset 64 uid (UID = u64; MSVC aligns uint64_t to 8 bytes)
offset 72 plugin_ptr
Both layouts are validated at compile time via const _: () = { ... }
asserting std::mem::offset_of!(OmpComponent, uid_vtable) and
std::mem::size_of::<OmpComponent>().
Primary vtable — IExtensible + IComponent¶
Itanium ABI — 17 slots¶
| Slot | Method | Notes |
|---|---|---|
| 0 | getExtension(UID) |
|
| 1 | addExtension(IExtension*, bool) |
|
| 2 | removeExtension(IExtension*) |
|
| 3 | removeExtension(UID) |
|
| 4 | ~destructor (D1 — complete object) |
Itanium emits two destructor slots per class. |
| 5 | ~destructor (D0 — deleting) |
|
| 6 | supportedVersion() -> i32 |
|
| 7 | componentName() -> StringView |
Returned by value (8 bytes on i686 Linux ABI). |
| 8 | componentType() -> ComponentType |
|
| 9 | componentVersion() -> SemanticVersion |
Returned by value (6 bytes). |
| 10 | onLoad(ICore*) |
|
| 11 | onInit(IComponentList*) |
|
| 12 | onReady() |
|
| 13 | onFree(IComponent*) |
|
| 14 | provideConfiguration(ILogger*, IEarlyConfig*, bool) |
|
| 15 | free() |
|
| 16 | reset() |
MSVC ABI — 16 slots¶
| Slot | Method | Notes |
|---|---|---|
| 0 | getExtension(UID) |
thiscall |
| 1 | addExtension(IExtension*, bool) |
thiscall |
| 2 | removeExtension(IExtension*) |
thiscall |
| 3 | removeExtension(UID) |
thiscall |
| 4 | ~destructor (scalar deleting) |
MSVC i686 with single inheritance emits a single destructor slot. |
| 5 | supportedVersion() -> i32 |
thiscall, no stack args (signature fn() so Rust emits ret, not ret 4). |
| 6 | componentName() -> StringView |
Returned via hidden pointer at [ESP+4] (naked asm; ret 4). |
| 7 | componentType() -> ComponentType |
thiscall, no stack args. |
| 8 | componentVersion() -> SemanticVersion |
Returned via hidden pointer at [ESP+4] (naked asm; ret 4). |
| 9 | onLoad(ICore*) |
|
| 10 | onInit(IComponentList*) |
|
| 11 | onReady() |
thiscall, no stack args. |
| 12 | onFree(IComponent*) |
|
| 13 | provideConfiguration(ILogger*, IEarlyConfig*, bool) |
|
| 14 | free() |
thiscall, no stack args. |
| 15 | reset() |
thiscall, no stack args. |
Why
fn()instead offn(*mut Self)for no-arg MSVC methods? Onthiscall,thisarrives inECX. Declaring an explicit_thisargument would make Rust emitret 4(cleaning up a stack slot that does not exist), corrupting the stack on return. Usingfn()emits the correctretand keepsECXsemantics intact.
Secondary vtable — IUIDProvider¶
Itanium ABI — 3 slots¶
| Slot | Method | Notes |
|---|---|---|
| 0 | ~destructor D1 thunk |
Inherited destructor thunks. The SDK supplies no-op implementations. |
| 1 | ~destructor D0 thunk |
|
| 2 | getUID() -> UID |
this points to the IUIDProvider subobject at offset 40. |
MSVC ABI — 1 slot¶
| Slot | Method | Notes |
|---|---|---|
| 0 | getUID() -> UID |
IUIDProvider declares no virtual destructor under MSVC. this points to the subobject at offset 56. Disasm of omp-server.exe confirms add ecx, 0x38; mov eax, [esi+0x38]; call [eax]. |
uid_get_uid recovers the original OmpComponent* by subtracting
offsetof(OmpComponent, uid_vtable) from the received this.
IPawnComponent vtable¶
IPawnComponent : public IComponent adds Pawn-specific virtuals
after the inherited slots. Runtime dumps on Open Multiplayer 1.5.8
confirmed the indices:
| ABI | Slot for getEventDispatcher |
Slot for getAmxFunctions |
|---|---|---|
| Itanium | 18 | 19 |
| MSVC | 16 | 17 |
AmxFunctionTable is a StaticArray<void*, 52> (52 slots) — this is
the NUM_AMX_FUNCS constant exposed by the SDK as
samp_sdk::omp::server::NUM_AMX_FUNCS.
getAmxFunctions()returns0duringon_initon the current Open Multiplayer (1.5.x). The SDK calls it adaptively: it tries once inon_init, stores the pointer if non-zero, and otherwise retries inon_ready. This way future versions that populate the table earlier work without any change.
IPawnScript vtable¶
No virtual destructor → identical slot layout on both ABIs. Only one slot is used:
| Slot | Method | Notes |
|---|---|---|
| 57 | GetAMX() -> *mut AMX |
Calling convention varies per ABI. |
IEventDispatcher<PawnEventHandler> vtable¶
No virtual destructor → identical slot layout on both ABIs.
| Slot | Method | Notes |
|---|---|---|
| 0 | addEventHandler(handler*, priority: i8) -> bool |
Used by add_pawn_event_handler. |
| 1 | removeEventHandler(handler*) -> bool |
Used by remove_pawn_event_handler. |
| 2 | hasEventHandler |
Unused by the SDK. |
| 3 | count |
Unused by the SDK. |
The dispatcher fires our own PawnEventHandler (a Rust object we
own) whose vtable has 2 slots:
| Slot | Method |
|---|---|
| 0 | onAmxLoad(IPawnScript*) |
| 1 | onAmxUnload(IPawnScript*) |
PawnEventHandler has no virtual destructor in the Open Multiplayer
header — the only difference between Itanium and MSVC is the
calling convention.
ITimersComponent and ITimer¶
ITimersComponent inherits from IComponent. The first new slot
after the 16 inherited ones (MSVC) or 17 (Itanium) is
create(handler, ms, repeating).
The SDK uses slot 16 through the shared helper
vtable::secondary_call_target(component_ptr, 0, 16), which works
on both ABIs because the helper reads from the primary vtable
pointer regardless of layout (only the slot index must match, and
in this case 16 happens to align on both ABIs).
ITimer::kill() lives at slot 10 and is called the same way.
ICore::ILogger subobject¶
ICore : public IExtensible, public ILogger. The ILogger subobject
sits after the IExtensible block, at different offsets per ABI:
| ABI | ILogger offset inside ICore |
|---|---|
| MSVC | 56 bytes (confirmed in Console.dll: lea edx, [core+0x38]; mov ecx, [edx]; call [ecx+8]). |
| Itanium | 40 bytes (confirmed in Console.so: add edi, 0x28; mov ebx, [edi]; call [ebx+8]). |
The ILogger vtable (8 slots, identical order on both ABIs):
| Slot | Method |
|---|---|
| 0 | printLn(fmt, ...) |
| 1 | vprintLn(fmt, va_list) |
| 2 | logLn(level, fmt, ...) |
| 3 | vlogLn(level, fmt, va_list) |
| 4 | printLnU8(fmt, ...) |
| 5 | vprintLnU8(fmt, va_list) |
| 6 | logLnU8(level, fmt, ...) |
| 7 | vlogLnU8(level, fmt, va_list) |
Variadic virtual methods on x86 use __cdecl on both MSVC and
Itanium — thiscall does not support varargs. Stable Rust does not
expose extern "C" variadic (c_variadic is nightly), so the SDK
declares each entry point with fixed arity (fn(this, fmt, arg))
and always passes fmt = "%s". The caller formats the message in
Rust and passes the resulting CString as the single variadic
argument — ABI-equivalent to the variadic call.
Helpers in samp_sdk::omp::vtable¶
Three small unsafe fns centralize the repeated pattern of
"adjust pointer to a subobject, read its vtable, load slot N":
| Function | Returns | Used for |
|---|---|---|
subobject_ptr(obj, offset) |
Option<*mut u8> |
Pointer adjustment for secondary bases. |
vtable_slot(subobject, slot) |
Option<usize> |
Read a slot pointer from a vtable. |
secondary_call_target(obj, off, n) |
Option<(*mut u8, usize)> |
Combination — (this_to_pass, fn_ptr) in one go. |
All three return None on null pointers, null vtables, or null
slot entries — they are the defensive layer that prevents an
incorrect ABI assumption from segfaulting.
Adding a new component wrapper¶
To wrap another Open Multiplayer component:
- Declare a
#[repr(C)] pub struct MyOpaque { _opaque: [u8; 0] }for the server-owned object. - Find the component's
UIDand any slot offsets you need (read the open.mp SDK header, then verify with disasm). - Use
samp::omp::vtable::secondary_call_targetto call methods — the SDK does this forcomponentName,componentVersion,create_repeating_timer,kill_timer, and theILoggercalls. - Implement
OmpComponentHandleon a#[derive(Debug, Clone, Copy)]MyComponent { ptr: NonNull<ServerComponent> }. The trait gives the SDK's typedomp_query::<MyComponent>()helper a way to instantiate the wrapper.
PawnComponent and TimersComponent are the in-tree references.
Verifying offsets against a binary¶
The defensive rule used throughout the SDK is: when the C++ ABI is unclear, disassemble an official binary instead of inferring from headers.
# Linux side
i686-w64-mingw32-objdump -dC Console.dll | less
objdump -dC omp-server.exe | less
# Search for "add ecx, 0x38" or "call dword ptr [eax+N]"
grep -nE '(add[[:space:]]+(ecx|edi),[[:space:]]+0x[0-9a-f]+|call[[:space:]]+\[(eax|ecx|edx)\+[0-9]+\])' dump.asm
Two regressions were caught this way:
- Initial guesses placed
uid_vtableat offset 40, then 48, on MSVC. Disasm confirmed the server emitsadd ecx, 0x38→ offset 56. - The
IUIDProviderMSVC vtable was initially modeled with 3 slots (matching Itanium). Disasm showedcall [eax]immediately after the offset adjustment → slot 0, single-slot vtable.
Both fixes are now compile-time-asserted; do not regress them.