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}