Engine calls
The entity API covers state thoroughly — EntityRef reads and writes any field, through pointer chains, liveness-gated. Calling a function the framework doesn’t wrap is different: those have to be signature-resolved and exposed by the core, so “s2script doesn’t wrap X” used to mean waiting for a release.
Plugin-declared engine calls close that. You declare the function in your own gamedata and call it from TypeScript, with generated types and load-time validation.
This is the unsafe module for a reason. Read the safety model below before shipping one.
Three pieces
Declare it in your own gamedata, named <plugin-name-without-scope>.gamedata.jsonc — mirroring the framework’s own core.gamedata.jsonc, where a gamedata file is named for whoever owns it. s2s build enforces the name.
// gamedata/burn.gamedata.jsonc (for a plugin named @me/burn)
{
"signatures": {
"CBaseModelEntity_Ignite": {
"linuxsteamrt64": {
"module": "libserver.so",
"pattern": "55 48 89 E5 41 56 66 41 0F 7E C6 …",
"resolve": "direct"
}
}
},
"calls": {
"ignite": {
"receiver": { "kind": "entity" },
"target": { "kind": "signature", "name": "CBaseModelEntity_Ignite" },
"args": ["float", "int", "entity", "float"],
"argNames": ["flFlameLifetime", "nFlags", "pAttacker", "flSize"],
"returns": "void"
}
}
} Point the manifest at it and declare the permission. A calls section without engine:calls fails the build — the capability should be visible before anyone installs your plugin.
"s2script": {
"gamedata": "gamedata/burn.gamedata.jsonc",
"permissions": ["engine:calls"]
} Then call it. s2s build generates .s2script/gamedata.d.ts from your gamedata, so the call is fully typed:
import { plugin } from '@s2script/sdk/plugin';
import { Engine } from '@s2script/sdk/unsafe';
import { Pawn } from '@s2script/cs2';
export default plugin((ctx) => {
// Resolve ONCE at load. Returns a plain callable, or null if the descriptor failed a gate.
const ignite = Engine.call('ignite');
ctx.commands.register('burn', (cmd) => {
if (!ignite) return cmd.reply(`unavailable: ${Engine.status('ignite')}`);
const pawn = Pawn.forSlot(cmd.callerSlot);
if (pawn?.isValid) ignite(pawn.ref, 10.0, 4, null, 0.0);
});
}); args is the marshalling contract the runtime uses; argNames is documentary only, but it’s what makes the generated signature readable:
ignite: (self: EntityRef, flFlameLifetime: number, nFlags: number,
pAttacker: EntityRef | null, flSize: number) => void; An undeclared call name is a TS2345; a wrong argument count is a TS2554 — the same gate that checks the rest of your plugin.
Why it’s safe enough
No raw pointer reaches your code. You hold a descriptor name. The core holds (index, serial) pairs. Only the native shim holds pointers. A call whose receiver went stale degrades to a no-op, and a call that returns an entity converts the returned pointer to a handle and runs it through the same liveness-gated path Pawn.forSlot uses — so a bogus pointer yields null, never a live reference into freed memory.
A vtable index is never trusted bare. A byte signature either matches your build or it doesn’t; it self-validates. A borrowed index doesn’t, so any kind: "vtable" target must carry a validate.prologue, and the build refuses one without it.
That rule is not theoretical. A slot that other frameworks’ gamedata labels DropActivePlayerWeapon is, on some builds, a GiveNamedItem thunk — valid, in-range executable code, so a range check passes it. Calling through would hand an entity pointer to a const char* parameter and misbehave silently. Only the prologue tells them apart:
"target": {
"kind": "vtable",
"class": "CCSPlayer_ItemServices",
"linuxsteamrt64": {
"index": 24,
"validate": { "prologue": "55 48 89 E5 41 57 41 56 …" }
}
} Failures are named, and never contagious. Each descriptor resolves independently at load. One that fails degrades on its own while the rest stay armed:
WARN: [engine-calls] '@me/burn' call 'dropWeapon' unavailable:
prologue mismatch (resolved slot is not the intended function)
[engine-calls] '@me/burn' armed 'ignite' (call id 0) Engine.status(name) returns that same reason at runtime, so a plugin can tell an operator “this build moved the signature” instead of silently doing nothing.
The operator decides
Declaring the permission is necessary but not sufficient. Until an operator lists your plugin id in addons/s2script/configs/permissions.json, every declared call resolves to null:
{ "engine:calls": ["@me/burn"] } It’s exact-match and default-deny. A plugin that isn’t listed still loads and runs normally — only its declared calls are degraded — so an operator’s oversight can’t take a server down.
Measure an effect, not a return value
This is the habit worth building. A declared call that resolves but receives a wrong argument returns cleanly and does nothing at all. Two ways that happens in practice:
- A flag bit you didn’t know was required.
IgnitewithnFlagsmissing bit0x4consults an “is this ignitable?” virtual, which is false for every player pawn, and returns having done nothing. - A float that doesn’t arrive. SysV passes a 32-bit float in the low 32 bits of the register. Get the width wrong anywhere in the chain and
10.0arrives as0.0f— no crash, no diagnostic, just a burn with zero duration.
Both look like success from the caller’s side. So verify the engine actually acted, rather than trusting that the call returned:
const before = Entity.findByClass('entityflame').length;
ignite(pawn.ref, 10.0, 4, null, 0.0);
const after = Entity.findByClass('entityflame').length;
// after > before → the engine really did something If you’re checking a float landed, check that its effect persists — for a 10-second burn, flames still alive after 4s and gone by 12s.
You own the treadmill
Your gamedata is yours to maintain. When CS2 updates, that byte pattern stops matching, the call degrades with a named reason, and the rest of your plugin keeps running. Re-deriving it is your job, not the framework’s.
Resolve engine facts against your own binary rather than copying a constant from another project. A borrowed value is a hint to verify, never an answer — that is exactly how a thunk gets mistaken for the function it forwards to.
Limits
The receiver is an entity by default, optionally hopping through one schema-named sub-object pointer (receiver.via). A static engine function — one with no this at all, which is what most engine factories are — declares "receiver": { "kind": "none" } instead; its generated callable takes no leading self, and via is rejected because there is no receiver to hop from.
Arguments come from a closed set — bool, int, float, string, vector, entity — and returns from void, bool, int, float, entity.
At most 9 integer-class arguments and 8 float arguments, plus the receiver when the descriptor has one. That isn’t a flat count of 17: everything except float is integer-class. Six is the SysV register count rather than a limit on the call — arguments past the sixth are passed on the stack. Exceeding either bound fails the build.
No struct-by-value, no out-params, no string returns. And this is calls only — plugin-declared hooks remain core-owned.
See also
@s2script/sdk/unsafe— the generated API referenceexamples/engine-call-demoin the monorepo — a complete worked plugin, including a descriptor that is meant to fail- Entities — the
EntityRefmodel these calls receive and return