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}