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;
25use crate::runtime::Runtime;
26
27// Detour machinery is x86/x86_64-only (retour supports no other arch, and
28// SA-MP/open.mp run only on 32-bit x86).
29#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
30use std::cell::RefCell;
31#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
32use std::collections::HashSet;
33#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
34use std::sync::OnceLock;
35
36#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
37use retour::GenericDetour;
38
39#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
40use samp_sdk::consts::AmxExecIdx;
41#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
42use samp_sdk::exports::{Exec, Export};
43
44/// What the SDK does with the gamemode's public after an event handler runs.
45///
46/// A `#[event]` handler may return this type to influence the callback. Handlers
47/// that instead return `AmxResult<T>` / `T` are pure **observers**: their value
48/// is ignored and the original public always runs (equivalent to `Continue`).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum EventReturn {
51    /// Let the gamemode's own public run as usual. The default for observers.
52    Continue,
53    /// Skip the gamemode's public entirely; the callback returns this raw cell
54    /// to its caller. Use to cancel a callback (e.g. reject a command in
55    /// `OnPlayerCommandText` by returning `EventReturn::Suppress(1)`).
56    ///
57    /// The value is a raw AMX cell. For a typed return (`f32`, `bool`, …) use
58    /// [`EventReturn::suppress`], which encodes the value to a cell for you.
59    Suppress(i32),
60}
61
62impl EventReturn {
63    /// Suppresses the callback, returning `value` encoded as an AMX cell.
64    ///
65    /// Convenience over `Suppress(i32)` for callbacks whose Pawn return type is
66    /// not a plain integer — a `Float:` callback wants the bit pattern of the
67    /// `f32`, a `bool:` callback wants `0`/`1`. `CellConvert` handles the
68    /// encoding, so `EventReturn::suppress(1.5_f32)` and
69    /// `EventReturn::suppress(true)` do the right thing.
70    ///
71    /// ```rust,ignore
72    /// #[event(name = "OnPlayerRequestScore")]
73    /// fn on_score(&mut self, _amx: &Amx, _id: i32) -> EventReturn {
74    ///     EventReturn::suppress(1.5_f32) // Float: callback, returns 1.5
75    /// }
76    /// ```
77    #[must_use]
78    pub fn suppress<T: samp_sdk::cell::CellConvert>(value: T) -> Self {
79        EventReturn::Suppress(value.into_cell())
80    }
81}
82
83/// Handler wrapper generated by `#[event]`.
84///
85/// Receives the `&Amx` and the [`Args`] the dispatcher built from the VM stack,
86/// parses the callback arguments into the declared Rust types, invokes the
87/// plugin method, and reports whether to run or suppress the original public.
88pub type EventHandler = fn(&Amx, &mut Args) -> EventReturn;
89
90/// Pawn callback name paired with its handler wrapper.
91///
92/// Produced by the `__samp_event_reg_*` function that `#[event]` generates and
93/// consumed by `initialize_plugin!(events: [...])`.
94#[derive(Clone, Copy)]
95pub struct EventInfo {
96    /// Pawn callback name, e.g. `"OnPlayerConnect"`.
97    pub name: &'static str,
98    /// Wrapper that parses arguments and dispatches into the plugin method.
99    pub handler: EventHandler,
100}
101
102/// Signature of the VM's `amx_Exec` — `(amx, retval, public index)`.
103#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
104type ExecFn = unsafe extern "C" fn(*mut AMX, *mut i32, i32) -> i32;
105
106/// Owns the live detour so it stays enabled for the process lifetime (dropping a
107/// [`GenericDetour`] removes the hook). A single detour covers every AMX — the
108/// server routes all public execution through the same function pointer.
109#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
110struct ExecDetour(GenericDetour<ExecFn>);
111
112// SAFETY: SA-MP and open.mp are single-threaded; the detour is only ever touched
113// on the main thread. This mirrors the `Runtime` Sync/Send rationale.
114#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
115unsafe impl Sync for ExecDetour {}
116#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
117unsafe impl Send for ExecDetour {}
118
119/// Installed lazily on the first AMX that carries events; `Some` thereafter.
120#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
121static EXEC_DETOUR: OnceLock<ExecDetour> = OnceLock::new();
122
123/// Resolves the registered events against a freshly loaded AMX and, on the
124/// first AMX that carries events, installs the `amx_Exec` detour.
125///
126/// No-op when the plugin registered no `#[event]` handlers.
127pub(crate) fn on_amx_load(rt: &Runtime, amx: &Amx) {
128    if !rt.has_events() {
129        return;
130    }
131    resolve_events_for_amx(rt, amx);
132    install_exec_hook(rt.amx_exports());
133}
134
135/// Drops the resolved handlers for an AMX being unloaded.
136pub(crate) fn on_amx_unload(rt: &Runtime, amx_ptr: *mut AMX) {
137    if rt.has_events() {
138        rt.remove_resolved_events(AmxIdent::from(amx_ptr));
139    }
140}
141
142/// For each registered event, resolves its public index in `amx` (via
143/// `amx_FindPublic`) and records `(ident, index, handler)` for dispatch. A
144/// callback the gamemode does not define is simply skipped.
145fn resolve_events_for_amx(rt: &Runtime, amx: &Amx) {
146    let Some(ptr) = amx.amx() else {
147        return;
148    };
149    let ident = AmxIdent::from(ptr.as_ptr());
150
151    // Clear any prior resolution for this AMX first, so a second `on_amx_load`
152    // for the same script (e.g. an open.mp pre-load path) cannot register
153    // duplicate handlers that would fire the callback more than once.
154    rt.remove_resolved_events(ident);
155
156    for event in rt.events_snapshot() {
157        if let Ok(idx) = amx.find_public(event.name) {
158            rt.push_resolved_event(ident, i32::from(idx), event.handler);
159        }
160    }
161}
162
163/// Installs the `amx_Exec` detour from the AMX function table. Idempotent —
164/// once the `OnceLock` is set every later call short-circuits.
165#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
166fn install_exec_hook(fn_table: usize) {
167    if EXEC_DETOUR.get().is_some() || fn_table == 0 {
168        return;
169    }
170
171    // `Exec::from_table` panics on a null table; guarded above. The returned
172    // safe `fn` coerces to the `unsafe extern "C" fn` the detour expects.
173    let target: ExecFn = Exec::from_table(fn_table);
174
175    // SAFETY: `target` is the server's real `amx_Exec`; retour builds a
176    // trampoline that preserves the original code. `exec_detour` never unwinds
177    // across the boundary (it wraps dispatch in `catch_unwind`).
178    let detour = match unsafe { GenericDetour::new(target, exec_detour) } {
179        Ok(detour) => detour,
180        Err(err) => {
181            log::warn!("[rust-samp] failed to build amx_Exec detour: {err}; events will not fire");
182            return;
183        }
184    };
185
186    // Store before enabling so a callback that fires mid-install already finds
187    // the detour and can reach the original trampoline.
188    let cell = EXEC_DETOUR.get_or_init(|| ExecDetour(detour));
189
190    // SAFETY: enabling rewrites the target prologue; retour keeps the original
191    // reachable via the trampoline used by `call`.
192    if let Err(err) = unsafe { cell.0.enable() } {
193        log::warn!("[rust-samp] failed to enable amx_Exec detour: {err}; events will not fire");
194    }
195}
196
197/// On non-x86 arches the detour library is unavailable, so events never fire.
198/// This keeps the public API (`#[event]`, `events: [...]`) compiling everywhere
199/// — the aarch64 check job builds the lib without a hook.
200#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
201fn install_exec_hook(_fn_table: usize) {}
202
203/// Trampoline installed in place of `amx_Exec`. Dispatches to matching event
204/// handlers; a handler may suppress the gamemode's public, otherwise it runs
205/// unchanged.
206///
207/// # Safety
208/// Installed by retour as the replacement for the VM's `amx_Exec`; the server
209/// calls it with the same arguments the original expects.
210#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
211unsafe extern "C" fn exec_detour(amx: *mut AMX, retval: *mut i32, index: i32) -> i32 {
212    // A panic must never cross back into the VM's C code. On panic, fall through
213    // to the original public (no suppression).
214    let suppressed =
215        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| dispatch(amx, index)))
216            .unwrap_or(None);
217
218    if let Some(value) = suppressed {
219        // A handler cancelled the callback: skip the original public, hand
220        // `value` back as its return value, and report success (AMX_ERR_NONE).
221        if !retval.is_null() {
222            unsafe { *retval = value };
223        }
224        return 0;
225    }
226
227    // SAFETY: delegates to retour's preserved trampoline with the original args.
228    match EXEC_DETOUR.get() {
229        Some(cell) => unsafe { cell.0.call(amx, retval, index) },
230        None => 0,
231    }
232}
233
234// Tracks the `(amx, public index)` pairs currently being dispatched on this
235// thread, so a handler that re-enters the VM on the *same* public does not
236// recurse into dispatch again (which could loop unbounded).
237#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
238thread_local! {
239    static ACTIVE: RefCell<HashSet<(usize, i32)>> = RefCell::new(HashSet::new());
240}
241
242/// RAII guard for the reentrancy set: [`acquire`] inserts the key (returning
243/// `None` if it was already dispatching) and `Drop` removes it — so the key is
244/// cleared even if a handler unwinds.
245///
246/// [`acquire`]: ActiveGuard::acquire
247#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
248struct ActiveGuard(usize, i32);
249
250#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
251impl ActiveGuard {
252    fn acquire(key: (usize, i32)) -> Option<Self> {
253        ACTIVE.with(|active| {
254            active
255                .borrow_mut()
256                .insert(key)
257                .then_some(ActiveGuard(key.0, key.1))
258        })
259    }
260}
261
262#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
263impl Drop for ActiveGuard {
264    fn drop(&mut self) {
265        ACTIVE.with(|active| {
266            active.borrow_mut().remove(&(self.0, self.1));
267        });
268    }
269}
270
271/// Core dispatch: for the public `index` being executed on `amx_ptr`, run every
272/// event handler registered for that `(amx, index)` pair, in registration order.
273///
274/// Returns `Some(value)` if a handler suppressed the callback (the first one to
275/// do so wins and the rest are skipped), `None` to run the gamemode's public.
276#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
277fn dispatch(amx_ptr: *mut AMX, index: i32) -> Option<i32> {
278    // Only user-defined publics carry gamemode callbacks; skip main/continue.
279    let AmxExecIdx::UserDef(idx) = AmxExecIdx::from(index) else {
280        return None;
281    };
282    if amx_ptr.is_null() {
283        return None;
284    }
285
286    let rt = Runtime::get();
287    let ident = AmxIdent::from(amx_ptr);
288    let handlers = rt.resolved_handlers(ident, idx);
289    if handlers.is_empty() {
290        return None;
291    }
292
293    // Reentrancy guard: a handler re-entering the same public runs it directly
294    // rather than dispatching again. Dropped (key cleared) on every return path,
295    // including a handler unwind.
296    let _guard = ActiveGuard::acquire((amx_ptr as usize, idx))?;
297
298    let amx = crate::amx::get(ident)?;
299    let params = read_stack_params(amx_ptr, amx)?;
300
301    let mut args = Args::new(amx, params.as_ptr());
302    for handler in handlers {
303        // Each handler reads the same argument list from the start.
304        args.reset();
305        if let EventReturn::Suppress(value) = handler(amx, &mut args) {
306            return Some(value);
307        }
308    }
309    None
310}
311
312/// Rebuilds the native-style parameter table (`[byte_count, arg0, arg1, …]`)
313/// from the callback arguments the gamemode pushed onto the VM stack, so the
314/// existing [`Args`] machinery can parse them exactly like a native call.
315///
316/// Returns `None` if the stack layout is inconsistent (negative param count or
317/// an out-of-bounds cell) — a corrupt frame is skipped rather than trusted.
318#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
319fn read_stack_params(amx_ptr: *mut AMX, amx: &Amx) -> Option<Vec<i32>> {
320    // SAFETY: `amx_ptr` is non-null (checked by the caller). `AMX` is `repr(C)`;
321    // `read_unaligned` is defensive and never assumes field alignment.
322    let (paramcount, stk) = unsafe {
323        (
324            std::ptr::addr_of!((*amx_ptr).paramcount).read_unaligned(),
325            std::ptr::addr_of!((*amx_ptr).stk).read_unaligned(),
326        )
327    };
328
329    if paramcount < 0 {
330        return None;
331    }
332    let count = paramcount as usize;
333
334    let mut params = Vec::with_capacity(count + 1);
335    // Args reads slot 0 as "bytes used by the arguments" and divides by 4.
336    params.push(paramcount.checked_mul(4)?);
337
338    for k in 0..count {
339        let offset = i32::try_from(k).ok()?.checked_mul(4)?;
340        let addr = stk.checked_add(offset)?;
341        params.push(amx.read_cell(addr)?);
342    }
343
344    Some(params)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use samp_sdk::cell::Ref;
351
352    fn handler_stub(_amx: &Amx, _args: &mut Args) -> EventReturn {
353        EventReturn::Continue
354    }
355
356    #[test]
357    fn event_info_is_copy_and_holds_fields() {
358        let info = EventInfo {
359            name: "OnPlayerConnect",
360            handler: handler_stub,
361        };
362        let copy = info;
363        assert_eq!(copy.name, "OnPlayerConnect");
364    }
365
366    #[test]
367    fn event_return_suppress_carries_value() {
368        assert_eq!(EventReturn::Suppress(1), EventReturn::Suppress(1));
369        assert_ne!(EventReturn::Continue, EventReturn::Suppress(0));
370    }
371
372    #[test]
373    fn event_return_typed_suppress_encodes_cells() {
374        // i32 identity, bool -> 0/1, f32 -> IEEE-754 bits.
375        assert_eq!(EventReturn::suppress(42_i32), EventReturn::Suppress(42));
376        assert_eq!(EventReturn::suppress(true), EventReturn::Suppress(1));
377        assert_eq!(EventReturn::suppress(false), EventReturn::Suppress(0));
378        assert_eq!(
379            EventReturn::suppress(1.5_f32),
380            EventReturn::Suppress(1.5_f32.to_bits().cast_signed())
381        );
382    }
383
384    #[test]
385    fn synthetic_params_parse_back_through_args() {
386        // A public with two integer args: build the native-style param table the
387        // dispatcher would hand to `Args` and verify round-tripping.
388        let params: [i32; 3] = [2 * 4, 7, 42];
389        let amx = Amx::new(std::ptr::null_mut(), 0);
390        let mut args = Args::new(&amx, params.as_ptr());
391        assert_eq!(args.count(), 2);
392        assert_eq!(args.next_arg::<i32>(), Some(7));
393        assert_eq!(args.next_arg::<i32>(), Some(42));
394        assert_eq!(args.next_arg::<i32>(), None);
395    }
396
397    #[test]
398    fn zero_arg_public_yields_empty_arg_list() {
399        let params: [i32; 1] = [0];
400        let amx = Amx::new(std::ptr::null_mut(), 0);
401        let args = Args::new(&amx, params.as_ptr());
402        assert_eq!(args.count(), 0);
403        assert!(args.get::<Ref<i32>>(0).is_none());
404    }
405}