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}