samp_sdk/omp/vtable.rs
1//! Helpers for accessing vtables (primary and secondary) of server-owned C++ objects.
2//!
3//! In C++ with multiple inheritance, each base class with virtuals results in a
4//! distinct vtable. The primary lies at offset 0 of the object; secondaries at
5//! offsets that depend on the `sizeof` of the preceding bases. The offsets are
6//! fixed per class and known at compile time (after layout analysis via disasm).
7//!
8//! This module centralizes the repeated pattern of:
9//!
10//! 1. Adjust the object pointer to point to a subobject (`obj + offset`).
11//! 2. Read the secondary vtable (`*subobject`).
12//! 3. Load the slot N pointer (`*(vtable + N * sizeof(usize))`).
13//!
14//! Each specific caller still performs the final `transmute` to the correct
15//! function type, because the calling convention varies (`extern "C"`,
16//! `extern "thiscall"`, variadic vs fixed arity).
17//!
18//! Slots are read and returned as `*const ()`, never as `usize`: a function
19//! pointer rebuilt from an integer carries no provenance, and calling it is
20//! undefined behavior under Rust's memory model.
21//!
22//! ## Example usage
23//!
24//! ```rust,no_run
25//! # use samp_sdk::omp::vtable;
26//! # use std::os::raw::{c_char, c_int};
27//! # type LogLnFn = unsafe extern "C" fn(*mut u8, c_int, *const c_char, *const c_char);
28//! # fn example(core: *mut u8, level: c_int, fmt: *const c_char, arg: *const c_char) -> Option<()> {
29//! // ILogger at offset 56 inside ICore; logLn at slot [2].
30//! let (this, f_ptr) = unsafe {
31//! vtable::secondary_call_target_ptr(core, 56, 2)?
32//! };
33//! let f: LogLnFn = unsafe { std::mem::transmute(f_ptr) };
34//! unsafe { f(this, level, fmt, arg) };
35//! # Some(()) }
36//! ```
37
38/// Returns the subobject pointer at `offset` bytes from `obj`.
39///
40/// For the primary base class (at offset 0), `offset = 0`. For secondary bases,
41/// the offset is determined by the `sizeof` of the preceding bases in C++.
42///
43/// Returns `None` if `obj` is null.
44///
45/// # Safety
46/// `obj` must be a valid pointer (or null). `offset` must be the correct offset
47/// of the subobject — passing the wrong offset produces an invalid pointer.
48#[inline]
49pub unsafe fn subobject_ptr(obj: *mut u8, offset: isize) -> Option<*mut u8> {
50 if obj.is_null() {
51 return None;
52 }
53 Some(unsafe { obj.offset(offset) })
54}
55
56/// Reads the slot `slot` pointer from the vtable pointed to by `subobject`.
57///
58/// Returns `None` if `subobject` is null, the vtable is null, or the slot
59/// contains zero (defensive against uninitialized or corrupted vtables).
60///
61/// # Safety
62/// `subobject` must point to a valid C++ object whose first member is the vptr.
63/// `slot` must be within the valid range of the vtable — reading a non-existent
64/// slot yields an undefined value (but not aliasing UB).
65#[deprecated(
66 since = "3.5.0",
67 note = "returns the address without provenance; use `vtable_slot_ptr`"
68)]
69#[inline]
70pub unsafe fn vtable_slot(subobject: *mut u8, slot: usize) -> Option<usize> {
71 unsafe { vtable_slot_ptr(subobject, slot) }.map(|f| f.addr())
72}
73
74/// Reads the slot `slot` function pointer from the vtable pointed to by `subobject`.
75///
76/// Returns `None` if `subobject` is null, the vtable is null, or the slot
77/// is null (defensive against uninitialized or corrupted vtables). The pointer
78/// keeps its provenance, so the caller may `transmute` it to a function type.
79///
80/// # Safety
81/// `subobject` must point to a valid C++ object whose first member is the vptr.
82/// `slot` must be within the valid range of the vtable.
83#[inline]
84pub unsafe fn vtable_slot_ptr(subobject: *mut u8, slot: usize) -> Option<*const ()> {
85 if subobject.is_null() {
86 return None;
87 }
88 // FFI: the first field of any C++ object with a virtual method is the
89 // vtable pointer, always pointer-aligned by the ABI (Itanium and MSVC).
90 #[allow(clippy::cast_ptr_alignment)]
91 let vtable = unsafe { *(subobject as *const *const *const ()) };
92 if vtable.is_null() {
93 return None;
94 }
95 let f_ptr = unsafe { *vtable.add(slot) };
96 if f_ptr.is_null() {
97 return None;
98 }
99 Some(f_ptr)
100}
101
102/// Combines [`subobject_ptr`] + [`vtable_slot_ptr`] in a single helper.
103///
104/// Returns `(this, f_ptr)`: the `this` adjusted for the subobject (the first
105/// arg of virtual method calls on that subobject) and the function pointer at
106/// the slot. The caller does the `transmute` to the correct function type and
107/// invokes it.
108///
109/// Returns `None` on any failure (`obj` null, vtable null, slot null).
110///
111/// # Safety
112/// See [`subobject_ptr`] and [`vtable_slot_ptr`].
113#[inline]
114pub unsafe fn secondary_call_target_ptr(
115 obj: *mut u8,
116 offset: isize,
117 slot: usize,
118) -> Option<(*mut u8, *const ())> {
119 let this = unsafe { subobject_ptr(obj, offset)? };
120 let f_ptr = unsafe { vtable_slot_ptr(this, slot)? };
121 Some((this, f_ptr))
122}
123
124/// Combines [`subobject_ptr`] + [`vtable_slot`] in a single helper.
125///
126/// Returns `(this, f_ptr)`: the `this` adjusted for the subobject (the first
127/// arg of virtual method calls on that subobject) and the function pointer at
128/// the slot. The caller does the `transmute` to the correct function type and
129/// invokes it.
130///
131/// Returns `None` on any failure (`obj` null, vtable null, slot zero).
132///
133/// # Safety
134/// See [`subobject_ptr`] and [`vtable_slot`].
135#[deprecated(
136 since = "3.5.0",
137 note = "returns the address without provenance; use `secondary_call_target_ptr`"
138)]
139#[inline]
140pub unsafe fn secondary_call_target(
141 obj: *mut u8,
142 offset: isize,
143 slot: usize,
144) -> Option<(*mut u8, usize)> {
145 unsafe { secondary_call_target_ptr(obj, offset, slot) }.map(|(this, f)| (this, f.addr()))
146}
147
148/// How a value comes back from a C++ virtual call, and what to declare the
149/// foreign function as returning to receive it safely.
150///
151/// A C++ function returning `bool`, `uint8_t` or a 16-bit integer sets only the
152/// low part of `EAX`; the rest of the register is left as it was. The official
153/// `IVehicle::isOccupied()` on Windows ORs two pointers into `EAX` and then
154/// `setne %al` — `true` comes back as `0x????..01`. Rust, told the function
155/// returns `bool`, assumes the register holds exactly 0 or 1, and a `bool` with
156/// any other bit pattern is undefined behaviour: a comparison may read the
157/// whole register and answer wrongly, with nothing to show for it.
158///
159/// So narrow types are received as the full register (`Raw`) and narrowed in
160/// Rust, where truncation is defined. Everything else comes back as it is.
161pub trait VirtualReturn: Sized {
162 /// The type the foreign function is declared to return.
163 type Raw;
164 /// The value the caller sees.
165 fn from_raw(raw: Self::Raw) -> Self;
166}
167
168impl VirtualReturn for bool {
169 type Raw = u32;
170 fn from_raw(raw: u32) -> bool {
171 // Only `AL` is defined; C++ puts 0 or 1 there.
172 raw & 0xff != 0
173 }
174}
175
176macro_rules! narrowed {
177 ($($ty:ty => $raw:ty),* $(,)?) => {$(
178 impl VirtualReturn for $ty {
179 type Raw = $raw;
180 #[allow(clippy::cast_possible_truncation)]
181 fn from_raw(raw: $raw) -> $ty {
182 // Truncation keeps the defined low bits and drops the rest.
183 raw as $ty
184 }
185 }
186 )*};
187}
188
189narrowed!(u8 => u32, i8 => i32, u16 => u32, i16 => i32);
190
191macro_rules! as_returned {
192 ($($ty:ty),* $(,)?) => {$(
193 impl VirtualReturn for $ty {
194 type Raw = $ty;
195 fn from_raw(raw: $ty) -> $ty {
196 raw
197 }
198 }
199 )*};
200}
201
202as_returned!(
203 (),
204 i32,
205 u32,
206 i64,
207 u64,
208 f32,
209 f64,
210 usize,
211 isize,
212 super::types::Vector3,
213 super::types::Vector4,
214 super::types::GTAQuat,
215 super::world::GangZonePos,
216);
217
218impl<T> VirtualReturn for *mut T {
219 type Raw = *mut T;
220 fn from_raw(raw: *mut T) -> *mut T {
221 raw
222 }
223}
224
225impl<T> VirtualReturn for *const T {
226 type Raw = *const T;
227 fn from_raw(raw: *const T) -> *const T {
228 raw
229 }
230}
231
232/// Calls a virtual method through a server object's vtable.
233///
234/// Every wrapper in this module family repeats the same four steps: name the
235/// function type for the target's calling convention, adjust `this` to the
236/// right subobject, read the slot, and give up gracefully when either pointer
237/// is missing. The macro is that sequence written once.
238///
239/// ```ignore
240/// // bool IPlayer::isBot() const, slot 8, primary vtable
241/// call_vtable!(player.cast::<u8>(), 0, SLOT_IS_BOT, () -> bool, (), false)
242///
243/// // void IPlayer::setHealth(float)
244/// call_vtable!(player.cast::<u8>(), 0, SLOT_SET_HEALTH, (f32) -> (), (health), ())
245/// ```
246///
247/// The last argument is what to return when the object, its vtable or the slot
248/// is null — the "fails closed" behaviour the null-safety tests check. Methods
249/// whose return type crosses the ABI differently (a struct through a hidden
250/// pointer, say) are written out by hand instead.
251macro_rules! call_vtable {
252 (
253 $ptr:expr, $offset:expr, $slot:expr,
254 ($($arg_ty:ty),* $(,)?) -> $ret:ty,
255 ($($arg:expr),* $(,)?),
256 $absent:expr
257 ) => {{
258 type Raw = <$ret as $crate::omp::vtable::VirtualReturn>::Raw;
259 #[cfg(not(target_env = "msvc"))]
260 type VirtualFn = unsafe extern "C" fn(*mut u8 $(, $arg_ty)*) -> Raw;
261 #[cfg(target_env = "msvc")]
262 type VirtualFn = unsafe extern "thiscall" fn(*mut u8 $(, $arg_ty)*) -> Raw;
263
264 match unsafe { $crate::omp::vtable::secondary_call_target_ptr($ptr, $offset, $slot) } {
265 Some((this, f_ptr)) => {
266 let call: VirtualFn = unsafe { std::mem::transmute(f_ptr) };
267 <$ret as $crate::omp::vtable::VirtualReturn>::from_raw(unsafe { call(this $(, $arg)*) })
268 }
269 None => $absent,
270 }
271 }};
272}
273
274pub(crate) use call_vtable;
275
276/// Calls a no-argument virtual method returning a small struct — at most eight
277/// bytes, trivially copyable: a `StringView`, a `SemanticVersion`.
278///
279/// The two ABIs disagree on where such a value comes back. Itanium returns it in
280/// `EAX:EDX`; MSVC writes it through a hidden pointer the caller passes after
281/// `this`, and returns that pointer. Declaring it the wrong way round reads
282/// whatever the registers held — or crashes the server, as it did when
283/// `IPlayer::getName` was first written. This is that rule, written once.
284///
285/// `$empty` is the value the MSVC out-parameter starts as. `None` when the
286/// object, its vtable or the slot is null.
287///
288/// A struct larger than eight bytes (`Vector3`) comes back through a hidden
289/// pointer on both ABIs, which plain [`call_vtable!`] already handles by
290/// declaring the return type.
291macro_rules! call_vtable_small_struct {
292 ($ptr:expr, $offset:expr, $slot:expr, $ret:ty, $empty:expr) => {
293 $crate::omp::vtable::call_vtable_small_struct!($ptr, $offset, $slot, $ret, $empty, () ())
294 };
295 // With arguments: under MSVC the hidden pointer comes first, before them.
296 ($ptr:expr, $offset:expr, $slot:expr, $ret:ty, $empty:expr, ($($arg_ty:ty),*) ($($arg:expr),*)) => {{
297 #[cfg(not(target_env = "msvc"))]
298 type VirtualFn = unsafe extern "C" fn(*mut u8 $(, $arg_ty)*) -> $ret;
299 #[cfg(target_env = "msvc")]
300 type VirtualFn = unsafe extern "thiscall" fn(*mut u8, *mut $ret $(, $arg_ty)*) -> *mut $ret;
301
302 match unsafe { $crate::omp::vtable::secondary_call_target_ptr($ptr, $offset, $slot) } {
303 Some((this, f_ptr)) => {
304 let call: VirtualFn = unsafe { std::mem::transmute(f_ptr) };
305 #[cfg(not(target_env = "msvc"))]
306 let value = unsafe { call(this $(, $arg)*) };
307 #[cfg(target_env = "msvc")]
308 let value = {
309 let mut out: $ret = $empty;
310 unsafe { call(this, &raw mut out $(, $arg)*) };
311 out
312 };
313 Some(value)
314 }
315 None => None,
316 }
317 }};
318}
319
320pub(crate) use call_vtable_small_struct;
321
322/// Declares constants whose value depends on the C++ ABI, one line each.
323///
324/// Slot indices and subobject offsets differ between Itanium (Linux) and MSVC
325/// (Windows), so every one of them used to be a pair of `#[cfg]`-gated
326/// declarations. This writes the pair from a single line, Itanium first:
327///
328/// ```ignore
329/// slots! {
330/// /// `IPlayer::kick()`.
331/// SLOT_PLAYER_KICK: usize = 6, 5;
332/// pub(crate) ENTITY_OFFSET: isize = 40, 56;
333/// }
334/// ```
335macro_rules! slots {
336 ($(
337 $(#[$meta:meta])*
338 $vis:vis $name:ident: $ty:ty = $itanium:expr, $msvc:expr;
339 )*) => {$(
340 $(#[$meta])*
341 #[cfg(not(target_env = "msvc"))]
342 $vis const $name: $ty = $itanium;
343 $(#[$meta])*
344 #[cfg(target_env = "msvc")]
345 $vis const $name: $ty = $msvc;
346 )*};
347}
348
349pub(crate) use slots;
350
351/// Declares opaque handles for server interfaces the SDK only ever holds by
352/// pointer.
353///
354/// ```ignore
355/// opaque! {
356/// /// Opaque handle for `IPlayerPool*`.
357/// pub IPlayerPool;
358/// }
359/// ```
360macro_rules! opaque {
361 ($(
362 $(#[$meta:meta])*
363 $vis:vis $name:ident;
364 )*) => {$(
365 $(#[$meta])*
366 #[repr(C)]
367 $vis struct $name {
368 _opaque: [u8; 0],
369 }
370 )*};
371}
372
373pub(crate) use opaque;
374
375/// Declares typed wrappers for virtual methods, one entry each.
376///
377/// Most of the SDK's surface is a thin, typed door onto one vtable slot: take
378/// the handle, call the slot, return what the server returns — or a neutral
379/// value when the handle, its vtable or the slot is null. Written out, each of
380/// those was a function signature around a single [`call_vtable!`]. This keeps
381/// the part that carries information — which slot, on which subobject, with
382/// which types, answering what when absent:
383///
384/// ```ignore
385/// virtual_fns! {
386/// /// `IPlayer::getHealth()`.
387/// #[must_use]
388/// pub fn player_health(player: IPlayer) -> f32 = [0, SLOT_PLAYER_GET_HEALTH] or 0.0;
389///
390/// /// `IPlayer::setHealth(float)`.
391/// pub fn player_set_health(player: IPlayer, health: f32) = [0, SLOT_PLAYER_SET_HEALTH];
392/// }
393/// ```
394///
395/// `[offset, slot]` is the subobject offset and the slot inside that
396/// subobject's vtable. A method with no return type needs no `or`. Every
397/// generated function is `unsafe`: the handle must be live, which only the
398/// caller can know.
399macro_rules! virtual_fns {
400 ($(
401 $(#[$meta:meta])*
402 $vis:vis fn $name:ident($this:ident: $handle:ty $(, $arg:ident: $arg_ty:ty)* $(,)?)
403 $(-> $ret:ty)? = [$offset:expr, $slot:expr] $(or $absent:expr)?;
404 )*) => {$(
405 $(#[$meta])*
406 // A wrapper takes what the C++ method takes, argument for argument:
407 // grouping them would read better alone and worse next to the header,
408 // which is what the wrapper has to be checked against.
409 #[allow(clippy::too_many_arguments)]
410 $vis unsafe fn $name($this: *mut $handle $(, $arg: $arg_ty)*) $(-> $ret)? {
411 $crate::omp::vtable::call_vtable!(
412 $this.cast::<u8>(),
413 $offset,
414 $slot,
415 ($($arg_ty),*) -> $crate::omp::vtable::virtual_fns!(@ret $($ret)?),
416 ($($arg),*),
417 $crate::omp::vtable::virtual_fns!(@absent $($absent)?)
418 )
419 }
420 )*};
421 (@ret) => { () };
422 (@ret $ret:ty) => { $ret };
423 (@absent) => { () };
424 (@absent $absent:expr) => { $absent };
425}
426
427pub(crate) use virtual_fns;
428
429/// Function-pointer table for unit-test mocks. Raw pointers are not `Sync`,
430/// so the wrapper lets a mock vtable live in a `static`.
431#[cfg(test)]
432pub(crate) struct MockTable<const N: usize>(pub [*const (); N]);
433
434// SAFETY: the table is written once at init and only read afterwards.
435#[cfg(test)]
436unsafe impl<const N: usize> Sync for MockTable<N> {}
437#[cfg(test)]
438unsafe impl<const N: usize> Send for MockTable<N> {}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 const DUMMY: [u8; 8] = [0; 8];
445
446 /// Fake function pointers into `DUMMY`: never called, only compared.
447 fn fake(i: usize) -> *const () {
448 DUMMY.as_ptr().wrapping_add(i).cast()
449 }
450
451 /// Creates a 32-pointer buffer; at `byte_offset` it installs the vptr for `table`.
452 fn make_obj_with_secondary_vtable(byte_offset: isize, table: &[*const ()]) -> [*const (); 32] {
453 let mut buf = [std::ptr::null::<()>(); 32];
454 let idx = usize::try_from(byte_offset).expect("byte_offset must be >= 0")
455 / std::mem::size_of::<*const ()>();
456 buf[idx] = table.as_ptr().cast();
457 buf
458 }
459
460 #[test]
461 fn subobject_ptr_returns_none_for_null() {
462 assert!(unsafe { subobject_ptr(std::ptr::null_mut(), 56) }.is_none());
463 }
464
465 #[test]
466 fn subobject_ptr_adds_offset_correctly() {
467 let mut buf = [0u8; 64];
468 let base = buf.as_mut_ptr();
469 let sub = unsafe { subobject_ptr(base, 56) }.unwrap();
470 assert_eq!(sub, base.wrapping_add(56));
471 }
472
473 #[test]
474 fn vtable_slot_ptr_returns_none_for_null_subobject() {
475 assert!(unsafe { vtable_slot_ptr(std::ptr::null_mut(), 0) }.is_none());
476 }
477
478 #[test]
479 fn vtable_slot_ptr_null_slot_returns_none() {
480 let table = [fake(0), fake(1), std::ptr::null()];
481 let mut buf = make_obj_with_secondary_vtable(0, &table);
482 let buf_u8 = buf.as_mut_ptr().cast::<u8>();
483 assert!(unsafe { vtable_slot_ptr(buf_u8, 2) }.is_none());
484 assert_eq!(unsafe { vtable_slot_ptr(buf_u8, 0) }, Some(fake(0)));
485 }
486
487 #[test]
488 fn secondary_call_target_ptr_combines_both() {
489 let table: Vec<*const ()> = (0..8).map(fake).collect();
490 let mut buf = make_obj_with_secondary_vtable(56, &table);
491 let buf_u8 = buf.as_mut_ptr().cast::<u8>();
492 let (this, f_ptr) = unsafe { secondary_call_target_ptr(buf_u8, 56, 3).unwrap() };
493 assert_eq!(this, buf_u8.wrapping_add(56));
494 assert_eq!(f_ptr, fake(3));
495 }
496
497 #[test]
498 fn secondary_call_target_ptr_null_obj_returns_none() {
499 assert!(unsafe { secondary_call_target_ptr(std::ptr::null_mut(), 56, 0) }.is_none());
500 }
501
502 #[test]
503 fn secondary_call_target_ptr_null_slot_returns_none() {
504 let table = [std::ptr::null::<()>()];
505 let mut buf = make_obj_with_secondary_vtable(8, &table);
506 let buf_u8 = buf.as_mut_ptr().cast::<u8>();
507 assert!(unsafe { secondary_call_target_ptr(buf_u8, 8, 0) }.is_none());
508 }
509
510 #[test]
511 #[allow(deprecated)]
512 fn deprecated_wrappers_return_the_same_address() {
513 let table: Vec<*const ()> = (0..8).map(fake).collect();
514 let mut buf = make_obj_with_secondary_vtable(56, &table);
515 let buf_u8 = buf.as_mut_ptr().cast::<u8>();
516 let (_, f) = unsafe { secondary_call_target(buf_u8, 56, 3).unwrap() };
517 assert_eq!(f, fake(3).addr());
518 let sub = buf_u8.wrapping_add(56);
519 assert_eq!(unsafe { vtable_slot(sub, 5) }, Some(fake(5).addr()));
520 }
521}