Natives¶
Natives are Rust functions exposed to the Pawn script. The #[native]
attribute generates the extern "C" FFI wrapper, parses each argument
from the AMX cell array, catches panics, and converts the return value
back into an AMX cell.
Basic shape¶
impl MyPlugin {
#[native(name = "MyNative")]
fn my_native(&mut self, amx: &Amx, /* arguments */) -> AmxResult</* type */> {
Ok(value)
}
}
Signature rules¶
- The first parameter is
&mut selffor plugin methods. Associated functions (noself) are also accepted — useful for stateless natives. - The next parameter is
&Amx. Use_amx: &Amxwhen the AMX handle is not needed. - Subsequent parameters are the native arguments, parsed via the
AmxCelltrait. - Returns either
AmxResult<T>/Result<T, E: Display>(the wrapper matchesOk/Err, logging the error and returning0onErr) orTdirectly (T: AmxCell<'static>) for infallible natives.
The macro detects the return type syntactically: if the last path segment is
ResultorAmxResult, the wrapper handles theOk/Errbranches. Any other return type is used as the cell value directly.
The name argument¶
name is the Pawn-visible identifier:
#[native(name = "GetPlayerScore")]
fn get_score(&mut self, _amx: &Amx, player_id: i32) -> AmxResult<i32> {
Ok(player_id * 100)
}
name is validated at proc-macro time — interior NUL bytes fail
compilation rather than panic at server load.
Argument types¶
Arguments are converted from AMX cells through AmxCell::from_raw:
| Rust type | Pawn equivalent | Notes |
|---|---|---|
i32 / u32 |
value |
Pawn cells are 32-bit integers. |
i8 / u8 / i16 / u16 / isize / usize |
value |
Cast from i32 via the Pawn ABI conventions. |
f32 |
Float:value |
Bit-reinterprets the cell (f32::from_bits). |
bool |
bool:value |
0 → false, any non-zero → true. |
&AmxString |
const string[] |
Recommended for input strings. Macro injects the borrow automatically. |
AmxString |
const string[] |
Same data, taken by value. |
Ref<T> |
&value |
Output by reference — write through *r. |
UnsizedBuffer |
array[] |
Unknown-length array; pair with a size argument and convert via into_sized_buffer. |
Strings — AmxString and &AmxString¶
AmxString implements Deref<Target = str>, Display, and
PartialEq<{&str, str, String}>. Every &str method is available
directly and the decoded string is computed once and cached in a
OnceCell<String>.
#[native(name = "ProcessName")]
fn process_name(&mut self, _amx: &Amx, name: &AmxString) -> AmxResult<bool> {
// Direct comparison with &str — no extra allocation.
if *name == "Admin" {
println!("[ADMIN] welcome!");
} else if name.starts_with("VIP_") {
println!("[VIP] welcome, {}!", &**name);
} else {
println!("Hello, {}! ({} chars)", &**name, name.len());
}
Ok(true)
}
Two patterns to obtain a &str from a &AmxString parameter:
- Concrete
&strparameter — let auto-deref do the work. Passingnameworks because&AmxStringderefs to&str. - Generic parameter (e.g.
T: AsRef<str>) — Rust does not apply deref coercion on generic bounds. Force it with&**name.
The decode is lazy: if the native never accesses the string content through
Deref, noStringis allocated. Use.to_string()only when aStringwith independent ownership is required.
Output strings — UnsizedBuffer::write_str¶
#[native(name = "GetPlayerInfo")]
fn get_player_info(
&mut self,
_amx: &Amx,
player_id: i32,
buffer: UnsizedBuffer,
size: usize,
) -> AmxResult<bool> {
let info = format!("Player #{player_id}");
buffer.write_str(size, &info)?;
Ok(true)
}
UnsizedBuffer::write_str(size, s) combines into_sized_buffer(size)
and the actual write in one step, propagating Err(AmxError::General)
when the encoded string is too long (no room for the terminator).
Typed arrays — get_as / set_as / iter_as¶
For Float:arr[] and bool:arr[] parameters, convert through
UnsizedBuffer::into_sized_buffer and use the typed accessors:
use samp::prelude::*;
#[native(name = "SumFloats")]
fn sum_floats(
&mut self,
_amx: &Amx,
array: UnsizedBuffer,
len: usize,
) -> AmxResult<f32> {
let buf = array.into_sized_buffer(len);
Ok(buf.iter_as::<f32>().sum())
}
#[native(name = "ScaleArray")]
fn scale_array(
&mut self,
_amx: &Amx,
array: UnsizedBuffer,
len: usize,
factor: f32,
) -> AmxResult<bool> {
let mut buf = array.into_sized_buffer(len);
for i in 0..buf.len() {
if let Some(v) = buf.get_as::<f32>(i) {
buf.set_as(i, v * factor);
}
}
Ok(true)
}
get_as/set_as/iter_asoperate through theCellConverttrait. Importing it explicitly is unnecessary —use samp::prelude::*;already brings it in.
| Trait | Use it when… |
|---|---|
AmxCell |
Declaring a native argument (AmxString, Ref<T>, primitive types). |
CellConvert |
Implementing typed-array support for a custom value type. |
Output by reference — Ref<T>¶
#[native(name = "GetHealth")]
fn get_health(
&mut self,
_amx: &Amx,
player_id: i32,
mut health: Ref<f32>,
) -> AmxResult<bool> {
*health = 100.0; // writes the Pawn variable directly
Ok(true)
}
Ref<T> requires T: AmxPrimitive and implements Deref and
DerefMut. Use address() to obtain the AMX-relative address when
re-passing the value to other AMX calls.
Raw mode¶
For full control over the argument array, opt into raw mode:
use samp::args::Args;
#[native(name = "RawNative", raw)]
fn raw_native(&mut self, amx: &Amx, args: Args) -> AmxResult<bool> {
let count = args.count();
let first: Option<i32> = args.get(0);
Ok(first.is_some() && count > 0)
}
Raw mode is useful when:
- The argument count is variable.
- Positional access is needed.
- Automatic conversion does not fit a custom protocol.
Return values¶
The return value is converted to i32 via AmxCell::as_cell:
| Rust return | Pawn observed value |
|---|---|
bool |
true → 1, false → 0 |
i32 |
Identity |
f32 |
Bit-reinterpretation (f32::to_bits) |
| Custom | <T as AmxCell>::as_cell(&value) of choice |
For Result<T, E> returns, the wrapper logs the Err via
samp::log::error! (formatted with Display) and returns 0.
Registering the native¶
Every native must appear in initialize_plugin!:
initialize_plugin!(
type: MyPlugin,
natives: [
MyPlugin::function_a,
MyPlugin::function_b,
MyPlugin::function_c,
],
);
Order is irrelevant — the Pawn-visible name is whatever was passed to
#[native(name = "...")], not the Rust method name.
Generating the Pawn include¶
#[native] derives each native's Pawn declaration from its Rust signature, so
the .inc the script side includes cannot drift from the code.
Start the server once with SAMP_PAWN_INCLUDE set to the path you want:
For the counter example that writes:
// Generated by rust-samp from the #[native] signatures of counter.
// Edits are lost on the next build — change the Rust side instead.
#if defined _counter_included
#endinput
#endif
#define _counter_included
native Counter_Increment();
native bool:Counter_Get(&out);
native bool:Counter_SetMax(max);
It works on either server and in either mode, since the declarations are stored
by both entry points. The same content is available programmatically through
samp::plugin::pawn_include() (a String) and
samp::plugin::write_pawn_include(path).
How types map¶
| Rust | Pawn |
|---|---|
i32, u32, usize, … |
arg |
f32 |
Float:arg |
bool |
bool:arg |
&AmxString / AmxString |
const arg[] |
Ref<i32> |
&arg |
Ref<f32> |
&Float:arg |
Buffer / UnsizedBuffer |
arg[] |
return bool / f32 |
bool: / Float: prefix on the native |
The mapping is syntactic: the macro reads the type as written, not as resolved.
A type it does not recognize becomes a plain cell argument — which is what the
AMX passes anyway, so only the Pawn tag is lost. A raw native comes back
commented out, because its arity is not in the signature:
Argument names come from the Rust parameters, with a leading _ stripped.
Saying what the signature cannot¶
Pawn declarations carry things no Rust signature has a place for: a default
value, a length argument that measures an array, varargs. #[native] takes them,
so the declaration stays derived instead of hand-written:
#[native(name = "Email_Status", default(account = 0), sizeof(dest_len = dest))]
fn status(&mut self, _amx: &Amx, account: i32, dest: UnsizedBuffer, dest_len: usize)
-> AmxResult<bool> { /* ... */ }
#[native(name = "Email_Send", raw, args = "account = 0, const to[], {Float,_}:...")]
fn send(&mut self, _amx: &Amx, args: Args) -> bool { /* ... */ }
native bool:Email_Status(account = 0, dest[], dest_len = sizeof(dest));
native bool:Email_Send(account = 0, const to[], {Float,_}:...);
In #[native(...)] |
Effect |
|---|---|
default(arg = 0) |
arg = 0. A string literal is taken verbatim, so default(name = "\"\"") gives name = "" |
sizeof(len = dest) |
len = sizeof(dest), Pawn's way of passing a buffer's size |
varargs |
closes the list with {Float,_}:... |
args = "..." |
the argument list, written out — the only way to declare a raw native |
default and sizeof naming an argument the function does not have is a compile
error: it means a rename on the Rust side that the attribute did not follow, and
the include would come out wrong.
Generating from a template¶
A fully generated include has no room for documentation; one kept by hand drifts.
A template is both: the prose, the sections, the constants and the callback
documentation stay written by hand, and every native line is filled in from the
Rust signature.
// counter v{{VERSION}} — generated from counter.inc.in
#if defined {{GUARD}}
#endinput
#endif
#define {{GUARD}}
// Called once a Counter_WorkAsync job has finished.
forward OnCounterWorkDone(delay);
// Adds one. Returns the new value, or -1 when already at the maximum.
{{NATIVE:Counter_Increment}}
// The open.mp-styled spelling of the same native.
{{NATIVE:Counter_Get as Counter_Read}}
{{NATIVES}}
| Placeholder | Becomes |
|---|---|
{{NATIVES}} |
every declaration not placed individually, in registration order |
{{NATIVE:Name}} |
that one declaration |
{{NATIVE:Name as Alias}} |
native Alias(…) = Name; — the same native under another name |
{{DOC:Name}} |
that native's documentation, as a Pawn documentation comment |
{{CALLBACKS}} |
a forward for each callback the plugin declared it calls |
{{PLUGIN}} |
the plugin's crate name |
{{GUARD}} |
_<plugin>_included |
{{VERSION}} |
the plugin crate's version, unless you pass your own |
| anything else | what you supplied with .var("NAME", value) |
A registered native that no placeholder emits is an error, not a silent
omission — that is the drift this exists to prevent. So are an unknown
placeholder, a {{NATIVE:...}} naming a native that does not exist, the same
declaration placed twice (Pawn rejects the second as an already defined symbol),
and a {{ that never closes. All of them come back at once, so one run names
everything.
Everything else in the file is yours and is left alone: a stock helper, a
#define shorthand, constants, #pragma library. Only declarations are
generated.
Documentation¶
Pawn has its own documentation format — the one the open.mp includes use and
pawncc -r reads into its XML report: a /** */ block of XML tags. #[native]
captures the Rust doc comment and the template renders it in that format, so the
documentation is written once, next to the code:
/// Adds one to the counter.
///
/// @returns The new value, or -1 when already at the maximum.
/// @seealso Counter_Decrement
#[native(name = "Counter_Increment")]
fn increment(&mut self, _amx: &Amx) -> i32 { /* ... */ }
/**
* <summary>Adds one to the counter.</summary>
* <returns>The new value, or -1 when already at the maximum.</returns>
* <seealso name="Counter_Decrement" />
*/
native Counter_Increment();
| In the Rust doc | In the include |
|---|---|
| the text | <summary> |
@param name text |
<param name="name">text</param> |
@returns text |
<returns>text</returns> |
@remarks text |
<remarks>text</remarks> |
@seealso Name |
<seealso name="Name" /> |
a line starting with < |
passed through untouched |
The passthrough is what makes the full format available: <library>, nested
markup, anything pawndoc accepts, written as you want it. Text that is not passed
through has &, < and > escaped, so a doc mentioning a < b cannot produce a
broken tag.
{{DOC:Name}} places one documentation block. Template::with_docs() puts every
native's documentation above its declaration, including inside {{NATIVES}} —
leave it off when the template documents each native in its own prose, or the
include gets two copies.
Callbacks¶
A script implements a plugin's callbacks as public, and the include is where
they are forwarded. Nothing in the Rust code says what they are —
exec_public! takes the name at the call site — so they are declared once, in
initialize_plugin!:
initialize_plugin!(
natives: [ /* ... */ ],
callbacks: [
"OnCounterWorkDone(delay)",
],
{ /* ... */ }
);
{{CALLBACKS}} turns each into forward OnCounterWorkDone(delay);, and
pawn_include::missing_forwards reports any the include does not forward — a
public a script implements for a callback the include forgot is simply never
called, with nothing to say why:
#[test]
fn every_callback_is_forwarded() {
let findings = samp::pawn_include::missing_forwards(&rendered, &pawn_callback_decls());
assert!(findings.is_empty(), "{findings:#?}");
}
A forward written by hand in the template counts: what matters is that the
callback is declared somewhere in the include, not where.
From a test¶
#[test]
fn the_shipped_include_is_the_rendered_template() {
let rendered = samp::pawn_include::Template::new(
include_str!("../counter.inc.in"),
&pawn_native_decls(),
)
.callbacks(&pawn_callback_decls())
.render()
.unwrap();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/counter.inc");
if std::env::var_os("UPDATE_INCLUDE").is_some() {
std::fs::write(path, &rendered).unwrap();
return;
}
assert_eq!(std::fs::read_to_string(path).unwrap(), rendered);
}
The include is committed, CI checks it is current, and UPDATE_INCLUDE=1 cargo
test regenerates it. The counter example works exactly this way.
Without writing any code¶
Start the server once with the template and the output named:
SAMP_PAWN_INCLUDE_TEMPLATE=include/my_plugin.inc.in \
SAMP_PAWN_INCLUDE=include/my_plugin.inc \
SAMP_PAWN_VAR_RELEASED=2026-09-26 \
./omp-server
SAMP_PAWN_VAR_<NAME> supplies {{<NAME>}}. Because the environment belongs to
the whole process, and a server may have several Rust plugins loaded, a value may
name the plugin it is for — otherwise they overwrite each other's file:
A bare path applies to whichever plugin reads it, which is what a single-plugin
setup wants. This selector works for SAMP_PAWN_INCLUDE,
SAMP_PAWN_INCLUDE_TEMPLATE and SAMP_PAWN_INCLUDE_CHECK.
Checking a hand-written include¶
A generated include cannot drift. One kept by hand can, and plugins keep theirs
by hand for reasons the Rust signature cannot express: default values,
dest_len = sizeof(dest), varargs, documentation the script author reads. The
cost is that a native renamed in Rust and forgotten in the .inc fails only
when a script calls it.
Point SAMP_PAWN_INCLUDE_CHECK at the include and the SDK compares it with the
natives actually registered, at load:
[my_plugin] include/my_plugin.inc disagrees with the registered natives:
[my_plugin] MyPlugin_Reset is registered but missing from the include; a script calling it will not compile
[my_plugin] MyPlugin_Legacy is declared in the include but not registered; a script calling it fails at runtime
[my_plugin] MyPlugin_Status takes 3 argument(s), the include declares 2
[my_plugin] MyPlugin_Get returns bool:, the include declares untagged
[my_plugin] MyPlugin_Read argument 2 is &Float:arg, the include declares arg
Compared: the name, the return tag, the argument count, and each argument's
tag, & and []. Not compared, because only the include can state them:
default values, size expressions, documentation, and varargs — a declaration
ending in ... is open-ended on purpose, so it may name fewer arguments than
the native takes.
Three things a real include does are understood rather than flagged:
- Aliases.
native Email_Close(account = 0) = email_close;presents a name the script calls, implemented by the registered native after the=. The comparison follows the=, so an include that renames the whole surface — an open.mp-styled variant next to the original, for instance — is not drift. Divergences are reported under the name the include declares. rawnatives. They parse their own arguments, so#[native]cannot derive a shape and the include is the only place it is written down. Presence is compared, shape is not.- Templates. A template is checked as what it renders to, so pointing the
check at the
.inc.inworks as well as pointing it at the output — and is usually what you want, the template being the file edited by hand. A template that will not render is reported here too.
In cargo test, without a server¶
initialize_plugin! also emits pawn_native_decls(), which needs no server, so
the whole check runs as an ordinary test — which is where a CI job wants it:
#[test]
fn the_include_matches_the_natives() {
let findings = samp::pawn_include::compare_file_with(
concat!(env!("CARGO_MANIFEST_DIR"), "/include/my_plugin.inc"),
&pawn_native_decls(),
)
.unwrap();
assert!(findings.is_empty(), "{findings:#?}");
}
The counter example ships one, next to the counter.inc it guards.
A plugin whose include is generated can write it from the same place, instead of
starting a server with SAMP_PAWN_INCLUDE set:
std::fs::write("include/my_plugin.inc",
samp::pawn_include::render("my_plugin", &pawn_native_decls()))?;
Inside a running plugin the same comparison is one call:
for finding in samp::pawn_include::compare_file("include/my_plugin.inc")? {
log::warn!("{finding}");
}
samp::pawn_include::parse reads declarations out of any Pawn source — it skips
commented-out ones, and handles declarations spread over several lines — and
compare_declarations compares two parsed lists without a server behind either.
Panic safety¶
The generated wrapper invokes the native body inside
std::panic::catch_unwind. A panic that would otherwise cross the
extern "C" boundary (which aborts the process on Rust 1.71+) is
captured, logged with the native name plus payload, and converted to a
0 return.