Skip to main content

samp/
events.rs

1//! Pawn callback interception for the `#[event]` macro.
2//!
3//! SA-MP and open.mp deliver gamemode callbacks (`OnPlayerConnect`,
4//! `OnPlayerSpawn`, …) only to the gamemode's own AMX — a plugin does not
5//! receive them by default. To observe a callback from Rust the SDK detours the
6//! VM's `amx_Exec`: every public invocation is inspected and, when its index
7//! matches a registered event on that AMX, the handler runs before the original
8//! public executes.
9//!
10//! The detour is installed lazily — only when the plugin registered at least one
11//! `#[event]` handler **and** the AMX function table is available. Plugins with
12//! no events never touch `amx_Exec`.
13//!
14//! Handlers are **observers** by default: a handler returning `AmxResult<T>` /
15//! `T` has its value ignored and the gamemode's public always runs. A handler
16//! that instead returns [`EventReturn`] can cancel the callback
17//! ([`EventReturn::Suppress`]) — the original public is skipped and the supplied
18//! value is returned in its place.
19
20use samp_sdk::amx::Amx;
21use samp_sdk::args::Args;
22use samp_sdk::raw::types::AMX;
23
24use crate::amx::AmxIdent;
25// Only the `amx_Exec` hook logs, and it exists only where `retour` can build one.
26#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
27use crate::macros::sdk_warn;
28use crate::runtime::Runtime;
29
30// Detour machinery is x86/x86_64-only (retour supports no other arch, and
31// SA-MP/open.mp run only on 32-bit x86).
32#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
33use std::sync::OnceLock;
34
35#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
36use retour::GenericDetour;
37
38#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
39use samp_sdk::consts::AmxExecIdx;
40#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
41use samp_sdk::exports::{Exec, Export};
42
43/// What the SDK does with the gamemode's public after an event handler runs.
44///
45/// A `#[event]` handler may return this type to influence the callback. Handlers
46/// that instead return `AmxResult<T>` / `T` are pure **observers**: their value
47/// is ignored and the original public always runs (equivalent to `Continue`).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum EventReturn {
50    /// Let the gamemode's own public run as usual. The default for observers.
51    Continue,
52    /// Skip the gamemode's public entirely; the callback returns this raw cell
53    /// to its caller. Use to cancel a callback (e.g. reject a command in
54    /// `OnPlayerCommandText` by returning `EventReturn::Suppress(1)`).
55    ///
56    /// The value is a raw AMX cell. For a typed return (`f32`, `bool`, …) use
57    /// [`EventReturn::suppress`], which encodes the value to a cell for you.
58    Suppress(i32),
59}
60
61impl EventReturn {
62    /// Suppresses the callback, returning `value` encoded as an AMX cell.
63    ///
64    /// Convenience over `Suppress(i32)` for callbacks whose Pawn return type is
65    /// not a plain integer — a `Float:` callback wants the bit pattern of the
66    /// `f32`, a `bool:` callback wants `0`/`1`. `CellConvert` handles the
67    /// encoding, so `EventReturn::suppress(1.5_f32)` and
68    /// `EventReturn::suppress(true)` do the right thing.
69    ///
70    /// ```rust,ignore
71    /// #[event(name = "OnPlayerRequestScore")]
72    /// fn on_score(&mut self, _amx: &Amx, _id: i32) -> EventReturn {
73    ///     EventReturn::suppress(1.5_f32) // Float: callback, returns 1.5
74    /// }
75    /// ```
76    #[must_use]
77    pub fn suppress<T: samp_sdk::cell::CellConvert>(value: T) -> Self {
78        EventReturn::Suppress(value.into_cell())
79    }
80}
81
82/// Handler wrapper generated by `#[event]`.
83///
84/// Receives the `&Amx` and the [`Args`] the dispatcher built from the VM stack,
85/// parses the callback arguments into the declared Rust types, invokes the
86/// plugin method, and reports whether to run or suppress the original public.
87pub type EventHandler = fn(&Amx, &mut Args) -> EventReturn;
88
89/// The handlers of one script, by public index: `None` for a public no
90/// handler watches.
91pub(crate) type EventTable = Vec<Option<std::rc::Rc<[EventHandler]>>>;
92
93/// Pawn callback name paired with its handler wrapper.
94///
95/// Produced by the `__samp_event_reg_*` function that `#[event]` generates and
96/// consumed by `initialize_plugin!(events: [...])`.
97#[derive(Clone, Copy)]
98pub struct EventInfo {
99    /// Pawn callback name, e.g. `"OnPlayerConnect"`.
100    pub name: &'static str,
101    /// Wrapper that parses arguments and dispatches into the plugin method.
102    pub handler: EventHandler,
103}
104
105/// Signature of the VM's `amx_Exec` — `(amx, retval, public index)`.
106#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
107type ExecFn = unsafe extern "C" fn(*mut AMX, *mut i32, i32) -> i32;
108
109/// Owns the live detour so it stays enabled for the process lifetime (dropping a
110/// [`GenericDetour`] removes the hook). A single detour covers every AMX — the
111/// server routes all public execution through the same function pointer.
112#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
113struct ExecDetour(GenericDetour<ExecFn>);
114
115// SAFETY: SA-MP and open.mp are single-threaded; the detour is only ever touched
116// on the main thread. This mirrors the `Runtime` Sync/Send rationale.
117#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
118unsafe impl Sync for ExecDetour {}
119#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
120unsafe impl Send for ExecDetour {}
121
122/// Installed lazily on the first AMX that carries events; `Some` thereafter.
123#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
124static EXEC_DETOUR: OnceLock<ExecDetour> = OnceLock::new();
125
126/// Resolves the registered events against a freshly loaded AMX and, on the
127/// first AMX that carries events, installs the `amx_Exec` detour.
128///
129/// No-op when the plugin registered no `#[event]` handlers.
130pub(crate) fn on_amx_load(rt: &Runtime, amx: &Amx) {
131    if !rt.has_events() {
132        return;
133    }
134    resolve_events_for_amx(rt, amx);
135    install_exec_hook(rt.amx_exports());
136}
137
138/// Drops the resolved handlers for an AMX being unloaded.
139pub(crate) fn on_amx_unload(rt: &Runtime, amx_ptr: *mut AMX) {
140    if rt.has_events() {
141        rt.remove_resolved_events(AmxIdent::from(amx_ptr));
142    }
143}
144
145/// For each registered event, resolves its public index in `amx` (via
146/// `amx_FindPublic`) and records `(ident, index, handler)` for dispatch. A
147/// callback the gamemode does not define is simply skipped.
148fn resolve_events_for_amx(rt: &Runtime, amx: &Amx) {
149    let Some(ptr) = amx.amx() else {
150        return;
151    };
152
153    let mut by_index: Vec<Vec<EventHandler>> = Vec::new();
154    for event in rt.events_snapshot() {
155        let Ok(index) = amx.find_public(event.name) else {
156            continue;
157        };
158        let Ok(index) = usize::try_from(i32::from(index)) else {
159            continue;
160        };
161        if by_index.len() <= index {
162            by_index.resize_with(index + 1, Vec::new);
163        }
164        by_index[index].push(event.handler);
165    }
166    let table: EventTable = by_index
167        .into_iter()
168        .map(|handlers| (!handlers.is_empty()).then(|| handlers.into()))
169        .collect();
170
171    // Replaces any prior resolution for this AMX, so a second `on_amx_load`
172    // for the same script (e.g. an open.mp pre-load path) cannot register
173    // duplicate handlers that would fire the callback more than once.
174    rt.set_resolved_events(AmxIdent::from(ptr.as_ptr()), table);
175}
176
177/// Installs the `amx_Exec` detour from the AMX function table. Idempotent —
178/// once the `OnceLock` is set every later call short-circuits.
179#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
180fn install_exec_hook(fn_table: usize) {
181    if EXEC_DETOUR.get().is_some() || fn_table == 0 {
182        return;
183    }
184
185    // The returned safe `fn` coerces to the `unsafe extern "C" fn` the detour
186    // expects. A table without `amx_Exec` leaves nothing to hook.
187    let Some(exec) = Exec::try_from_table(fn_table) else {
188        sdk_warn!("the AMX function table has no amx_Exec; #[event] handlers will not fire");
189        return;
190    };
191    let target: ExecFn = exec;
192
193    // SAFETY: `target` is the server's real `amx_Exec`; retour builds a
194    // trampoline that preserves the original code. `exec_detour` never unwinds
195    // across the boundary (it wraps dispatch in `catch_unwind`).
196    let detour = match unsafe { GenericDetour::new(target, exec_detour) } {
197        Ok(detour) => detour,
198        Err(err) => {
199            sdk_warn!("failed to build amx_Exec detour: {err}; events will not fire");
200            return;
201        }
202    };
203
204    // Store before enabling so a callback that fires mid-install already finds
205    // the detour and can reach the original trampoline.
206    let cell = EXEC_DETOUR.get_or_init(|| ExecDetour(detour));
207
208    // SAFETY: enabling rewrites the target prologue; retour keeps the original
209    // reachable via the trampoline used by `call`.
210    if let Err(err) = unsafe { cell.0.enable() } {
211        sdk_warn!("failed to enable amx_Exec detour: {err}; events will not fire");
212    }
213}
214
215/// On non-x86 arches the detour library is unavailable, so events never fire.
216/// This keeps the public API (`#[event]`, `events: [...]`) compiling everywhere
217/// — the aarch64 check job builds the lib without a hook.
218#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
219fn install_exec_hook(_fn_table: usize) {}
220
221/// Trampoline installed in place of `amx_Exec`. Dispatches to matching event
222/// handlers; a handler may suppress the gamemode's public, otherwise it runs
223/// unchanged.
224///
225/// # Safety
226/// Installed by retour as the replacement for the VM's `amx_Exec`; the server
227/// calls it with the same arguments the original expects.
228#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
229unsafe extern "C" fn exec_detour(amx: *mut AMX, retval: *mut i32, index: i32) -> i32 {
230    // A panic must never cross back into the VM's C code. On panic, fall through
231    // to the original public (no suppression).
232    let suppressed = crate::panic_guard::catch(|| dispatch(amx, index)).unwrap_or(None);
233
234    if let Some(value) = suppressed {
235        // A handler cancelled the callback: skip the original public, hand
236        // `value` back as its return value, and report success (AMX_ERR_NONE).
237        // The arguments the caller pushed are consumed as the call would have.
238        unsafe { consume_arguments(amx) };
239        if !retval.is_null() {
240            unsafe { *retval = value };
241        }
242        return 0;
243    }
244
245    // SAFETY: delegates to retour's preserved trampoline with the original args.
246    match EXEC_DETOUR.get() {
247        Some(cell) => unsafe { cell.0.call(amx, retval, index) },
248        None => 0,
249    }
250}
251
252/// What `amx_Exec` does with the arguments of a public it runs: the caller
253/// pushed them (`amx_Push`, `paramcount` counting them), and the call takes
254/// them off the stack (`stk += paramcount * cell`, `paramcount = 0`). A
255/// suppressed public skips the call, so it must do the same — otherwise each
256/// one leaves its arguments on the script's stack and a stale `paramcount` for
257/// the next call, and in a few hundred calls the script's stack runs into its
258/// heap and the script stops running.
259///
260/// # Safety
261/// `amx` must be null or point to a live `AMX`.
262#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
263unsafe fn consume_arguments(amx: *mut AMX) {
264    if amx.is_null() {
265        return;
266    }
267    // SAFETY: `amx` is a live `AMX` (caller's contract); the fields are read
268    // and written unaligned, as everywhere else for this packed struct.
269    unsafe {
270        let paramcount = std::ptr::addr_of!((*amx).paramcount).read_unaligned();
271        let stk = std::ptr::addr_of!((*amx).stk).read_unaligned();
272        let consumed = paramcount
273            .max(0)
274            .checked_mul(4)
275            .and_then(|bytes| stk.checked_add(bytes));
276        if let Some(stk) = consumed {
277            std::ptr::addr_of_mut!((*amx).stk).write_unaligned(stk);
278        }
279        std::ptr::addr_of_mut!((*amx).paramcount).write_unaligned(0);
280    }
281}
282
283/// Marks `(amx, public index)` as being dispatched for as long as it lives, so
284/// a handler that re-enters the VM on the *same* public does not recurse into
285/// dispatch again (which could loop unbounded). Cleared on drop, including when
286/// a handler unwinds.
287#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
288struct ActiveGuard<'rt> {
289    rt: &'rt Runtime,
290    key: (AmxIdent, i32),
291}
292
293#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
294impl<'rt> ActiveGuard<'rt> {
295    fn acquire(rt: &'rt Runtime, ident: AmxIdent, index: i32) -> Option<Self> {
296        rt.enter_dispatch(ident, index).then_some(ActiveGuard {
297            rt,
298            key: (ident, index),
299        })
300    }
301}
302
303#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
304impl Drop for ActiveGuard<'_> {
305    fn drop(&mut self) {
306        self.rt.leave_dispatch(self.key.0, self.key.1);
307    }
308}
309
310/// Core dispatch: for the public `index` being executed on `amx_ptr`, run every
311/// event handler registered for that `(amx, index)` pair, in registration order.
312///
313/// Returns `Some(value)` if a handler suppressed the callback (the first one to
314/// do so wins and the rest are skipped), `None` to run the gamemode's public.
315#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
316fn dispatch(amx_ptr: *mut AMX, index: i32) -> Option<i32> {
317    // Only user-defined publics carry gamemode callbacks; skip main/continue.
318    let AmxExecIdx::UserDef(idx) = AmxExecIdx::from(index) else {
319        return None;
320    };
321    if amx_ptr.is_null() {
322        return None;
323    }
324
325    let rt = Runtime::get();
326    let ident = AmxIdent::from(amx_ptr);
327    let handlers = rt.resolved_handlers(ident, idx)?;
328
329    // Reentrancy guard: a handler re-entering the same public runs it directly
330    // rather than dispatching again. Dropped (key cleared) on every return path,
331    // including a handler unwind.
332    let _guard = ActiveGuard::acquire(rt, ident, idx)?;
333
334    let amx = crate::amx::get(ident)?;
335    let mut inline = [0i32; INLINE_PARAMS + 1];
336    let mut spilled = Vec::new();
337    let params = read_stack_params(amx_ptr, amx, &mut inline, &mut spilled)?;
338
339    let mut args = Args::new(amx, params.as_ptr());
340    for handler in handlers.iter() {
341        // Each handler reads the same argument list from the start.
342        args.reset();
343        if let EventReturn::Suppress(value) = handler(amx, &mut args) {
344            return Some(value);
345        }
346    }
347    None
348}
349
350/// Callbacks with up to this many arguments are read into a buffer on the
351/// stack; longer ones (rare) into a `Vec`.
352#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
353const INLINE_PARAMS: usize = 16;
354
355/// Rebuilds the native-style parameter table (`[byte_count, arg0, arg1, …]`)
356/// from the callback arguments the gamemode pushed onto the VM stack, so the
357/// existing [`Args`] machinery can parse them exactly like a native call.
358/// The table goes into `inline` when it fits, otherwise into `spilled`.
359///
360/// Returns `None` if the stack layout is inconsistent (negative param count or
361/// an out-of-bounds cell) — a corrupt frame is skipped rather than trusted.
362#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
363fn read_stack_params<'b>(
364    amx_ptr: *mut AMX,
365    amx: &Amx,
366    inline: &'b mut [i32; INLINE_PARAMS + 1],
367    spilled: &'b mut Vec<i32>,
368) -> Option<&'b mut [i32]> {
369    // SAFETY: `amx_ptr` is non-null (checked by the caller). `AMX` is `repr(C)`;
370    // `read_unaligned` is defensive and never assumes field alignment.
371    let (paramcount, stk) = unsafe {
372        (
373            std::ptr::addr_of!((*amx_ptr).paramcount).read_unaligned(),
374            std::ptr::addr_of!((*amx_ptr).stk).read_unaligned(),
375        )
376    };
377
378    let count = usize::try_from(paramcount).ok()?;
379    let params: &mut [i32] = if count <= INLINE_PARAMS {
380        &mut inline[..=count]
381    } else {
382        spilled.resize(count + 1, 0);
383        spilled
384    };
385
386    // Args reads slot 0 as "bytes used by the arguments" and divides by 4.
387    params[0] = paramcount.checked_mul(4)?;
388    let mut addr = stk;
389    for slot in &mut params[1..] {
390        *slot = amx.read_cell(addr)?;
391        addr = addr.checked_add(4)?;
392    }
393
394    Some(params)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use samp_sdk::cell::Ref;
401
402    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
403    #[test]
404    fn a_suppressed_public_consumes_its_arguments_like_amx_exec() {
405        extern "C" fn callback(_: *mut AMX, _: i32, _: *mut i32, _: *mut i32) -> i32 {
406            0
407        }
408        extern "C" fn debug(_: *mut AMX) -> i32 {
409            0
410        }
411        // Two arguments pushed: the stack went down 8 bytes from 4096.
412        let mut amx = AMX {
413            base: std::ptr::null_mut(),
414            data: std::ptr::null_mut(),
415            callback,
416            debug,
417            cip: 0,
418            frm: 0,
419            hea: 0,
420            hlw: 0,
421            stk: 4096 - 8,
422            stp: 4096,
423            flags: 0,
424            usertags: [0; 4],
425            userdata: [std::ptr::null_mut(); 4],
426            error: 0,
427            paramcount: 2,
428            pri: 0,
429            alt: 0,
430            reset_stk: 0,
431            reset_hea: 0,
432            sysreq_d: 0,
433        };
434        unsafe { consume_arguments(&raw mut amx) };
435        assert_eq!({ amx.stk }, 4096);
436        assert_eq!({ amx.paramcount }, 0);
437
438        // Nothing pushed: nothing to consume.
439        unsafe { consume_arguments(&raw mut amx) };
440        assert_eq!({ amx.stk }, 4096);
441        unsafe { consume_arguments(std::ptr::null_mut()) };
442    }
443
444    fn handler_stub(_amx: &Amx, _args: &mut Args) -> EventReturn {
445        EventReturn::Continue
446    }
447
448    #[test]
449    fn event_info_is_copy_and_holds_fields() {
450        let info = EventInfo {
451            name: "OnPlayerConnect",
452            handler: handler_stub,
453        };
454        let copy = info;
455        assert_eq!(copy.name, "OnPlayerConnect");
456    }
457
458    #[test]
459    fn event_return_suppress_carries_value() {
460        assert_eq!(EventReturn::Suppress(1), EventReturn::Suppress(1));
461        assert_ne!(EventReturn::Continue, EventReturn::Suppress(0));
462    }
463
464    #[test]
465    fn event_return_typed_suppress_encodes_cells() {
466        // i32 identity, bool -> 0/1, f32 -> IEEE-754 bits.
467        assert_eq!(EventReturn::suppress(42_i32), EventReturn::Suppress(42));
468        assert_eq!(EventReturn::suppress(true), EventReturn::Suppress(1));
469        assert_eq!(EventReturn::suppress(false), EventReturn::Suppress(0));
470        assert_eq!(
471            EventReturn::suppress(1.5_f32),
472            EventReturn::Suppress(1.5_f32.to_bits().cast_signed())
473        );
474    }
475
476    #[test]
477    fn synthetic_params_parse_back_through_args() {
478        // A public with two integer args: build the native-style param table the
479        // dispatcher would hand to `Args` and verify round-tripping.
480        let params: [i32; 3] = [2 * 4, 7, 42];
481        let amx = Amx::new(std::ptr::null_mut(), 0);
482        let mut args = Args::new(&amx, params.as_ptr());
483        assert_eq!(args.count(), 2);
484        assert_eq!(args.next_arg::<i32>(), Some(7));
485        assert_eq!(args.next_arg::<i32>(), Some(42));
486        assert_eq!(args.next_arg::<i32>(), None);
487    }
488
489    #[test]
490    fn zero_arg_public_yields_empty_arg_list() {
491        let params: [i32; 1] = [0];
492        let amx = Amx::new(std::ptr::null_mut(), 0);
493        let args = Args::new(&amx, params.as_ptr());
494        assert_eq!(args.count(), 0);
495        assert!(args.get::<Ref<i32>>(0).is_none());
496    }
497}