Skip to main content

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/// Calls a virtual method through a server object's vtable.
149///
150/// Every wrapper in this module family repeats the same four steps: name the
151/// function type for the target's calling convention, adjust `this` to the
152/// right subobject, read the slot, and give up gracefully when either pointer
153/// is missing. The macro is that sequence written once.
154///
155/// ```ignore
156/// // bool IPlayer::isBot() const, slot 8, primary vtable
157/// call_vtable!(player.cast::<u8>(), 0, SLOT_IS_BOT, () -> bool, (), false)
158///
159/// // void IPlayer::setHealth(float)
160/// call_vtable!(player.cast::<u8>(), 0, SLOT_SET_HEALTH, (f32) -> (), (health), ())
161/// ```
162///
163/// The last argument is what to return when the object, its vtable or the slot
164/// is null — the "fails closed" behaviour the null-safety tests check. Methods
165/// whose return type crosses the ABI differently (a struct through a hidden
166/// pointer, say) are written out by hand instead.
167macro_rules! call_vtable {
168    (
169        $ptr:expr, $offset:expr, $slot:expr,
170        ($($arg_ty:ty),* $(,)?) -> $ret:ty,
171        ($($arg:expr),* $(,)?),
172        $absent:expr
173    ) => {{
174        #[cfg(not(target_env = "msvc"))]
175        type VirtualFn = unsafe extern "C" fn(*mut u8 $(, $arg_ty)*) -> $ret;
176        #[cfg(target_env = "msvc")]
177        type VirtualFn = unsafe extern "thiscall" fn(*mut u8 $(, $arg_ty)*) -> $ret;
178
179        match unsafe { $crate::omp::vtable::secondary_call_target_ptr($ptr, $offset, $slot) } {
180            Some((this, f_ptr)) => {
181                let call: VirtualFn = unsafe { std::mem::transmute(f_ptr) };
182                unsafe { call(this $(, $arg)*) }
183            }
184            None => $absent,
185        }
186    }};
187}
188
189pub(crate) use call_vtable;
190
191/// Function-pointer table for unit-test mocks. Raw pointers are not `Sync`,
192/// so the wrapper lets a mock vtable live in a `static`.
193#[cfg(test)]
194pub(crate) struct MockTable<const N: usize>(pub [*const (); N]);
195
196// SAFETY: the table is written once at init and only read afterwards.
197#[cfg(test)]
198unsafe impl<const N: usize> Sync for MockTable<N> {}
199#[cfg(test)]
200unsafe impl<const N: usize> Send for MockTable<N> {}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    const DUMMY: [u8; 8] = [0; 8];
207
208    /// Fake function pointers into `DUMMY`: never called, only compared.
209    fn fake(i: usize) -> *const () {
210        DUMMY.as_ptr().wrapping_add(i).cast()
211    }
212
213    /// Creates a 32-pointer buffer; at `byte_offset` it installs the vptr for `table`.
214    fn make_obj_with_secondary_vtable(byte_offset: isize, table: &[*const ()]) -> [*const (); 32] {
215        let mut buf = [std::ptr::null::<()>(); 32];
216        let idx = usize::try_from(byte_offset).expect("byte_offset must be >= 0")
217            / std::mem::size_of::<*const ()>();
218        buf[idx] = table.as_ptr().cast();
219        buf
220    }
221
222    #[test]
223    fn subobject_ptr_returns_none_for_null() {
224        assert!(unsafe { subobject_ptr(std::ptr::null_mut(), 56) }.is_none());
225    }
226
227    #[test]
228    fn subobject_ptr_adds_offset_correctly() {
229        let mut buf = [0u8; 64];
230        let base = buf.as_mut_ptr();
231        let sub = unsafe { subobject_ptr(base, 56) }.unwrap();
232        assert_eq!(sub, base.wrapping_add(56));
233    }
234
235    #[test]
236    fn vtable_slot_ptr_returns_none_for_null_subobject() {
237        assert!(unsafe { vtable_slot_ptr(std::ptr::null_mut(), 0) }.is_none());
238    }
239
240    #[test]
241    fn vtable_slot_ptr_null_slot_returns_none() {
242        let table = [fake(0), fake(1), std::ptr::null()];
243        let mut buf = make_obj_with_secondary_vtable(0, &table);
244        let buf_u8 = buf.as_mut_ptr().cast::<u8>();
245        assert!(unsafe { vtable_slot_ptr(buf_u8, 2) }.is_none());
246        assert_eq!(unsafe { vtable_slot_ptr(buf_u8, 0) }, Some(fake(0)));
247    }
248
249    #[test]
250    fn secondary_call_target_ptr_combines_both() {
251        let table: Vec<*const ()> = (0..8).map(fake).collect();
252        let mut buf = make_obj_with_secondary_vtable(56, &table);
253        let buf_u8 = buf.as_mut_ptr().cast::<u8>();
254        let (this, f_ptr) = unsafe { secondary_call_target_ptr(buf_u8, 56, 3).unwrap() };
255        assert_eq!(this, buf_u8.wrapping_add(56));
256        assert_eq!(f_ptr, fake(3));
257    }
258
259    #[test]
260    fn secondary_call_target_ptr_null_obj_returns_none() {
261        assert!(unsafe { secondary_call_target_ptr(std::ptr::null_mut(), 56, 0) }.is_none());
262    }
263
264    #[test]
265    fn secondary_call_target_ptr_null_slot_returns_none() {
266        let table = [std::ptr::null::<()>()];
267        let mut buf = make_obj_with_secondary_vtable(8, &table);
268        let buf_u8 = buf.as_mut_ptr().cast::<u8>();
269        assert!(unsafe { secondary_call_target_ptr(buf_u8, 8, 0) }.is_none());
270    }
271
272    #[test]
273    #[allow(deprecated)]
274    fn deprecated_wrappers_return_the_same_address() {
275        let table: Vec<*const ()> = (0..8).map(fake).collect();
276        let mut buf = make_obj_with_secondary_vtable(56, &table);
277        let buf_u8 = buf.as_mut_ptr().cast::<u8>();
278        let (_, f) = unsafe { secondary_call_target(buf_u8, 56, 3).unwrap() };
279        assert_eq!(f, fake(3).addr());
280        let sub = buf_u8.wrapping_add(56);
281        assert_eq!(unsafe { vtable_slot(sub, 5) }, Some(fake(5).addr()));
282    }
283}