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. The same file can declare inbound hooks — detours you subscribe to with Engine.hook.
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 { command, HookResult } from '@s2script/sdk';
import { Engine } from '@s2script/sdk/unsafe';
import { Pawn } from '@s2script/cs2';
export function OnPluginStart(): void {
// Resolve ONCE at load. Returns a plain callable, or null if the descriptor failed a gate.
const ignite = Engine.call('ignite');
command('burn', (cmd) => {
if (!ignite) {
cmd.reply(`unavailable: ${Engine.status('ignite')}`);
return HookResult.Handled;
}
const pawn = Pawn.forSlot(cmd.callerSlot);
if (pawn?.isValid) ignite(pawn.ref, 10.0, 4, null, 0.0);
return HookResult.Handled;
});
} 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.
Inbound hooks
The same gamedata file can declare inbound detours. s2s build accepts a hooks section together with the separate engine:hooks permission — an operator who granted outbound calls has not granted inbound patches.
"s2script": {
"gamedata": "gamedata/store.gamedata.jsonc",
"permissions": ["engine:hooks"]
} s2s build generates .s2script/hooks.d.ts, which augments EngineHooks on @s2script/sdk/unsafe. Subscribe through Engine.hook — not through a named public. The owner is always the calling plugin; you cannot name another plugin’s detour through this API. Game-package hooks (onTerminateRound, onRespawn, …) hang off gameRules / players from @s2script/cs2.
import { Engine } from '@s2script/sdk/unsafe';
export function OnPluginStart(): void {
const onFoo = Engine.hook('onFoo');
if (!onFoo) {
console.log(`unavailable: ${Engine.hookStatus('onFoo')}`);
return;
}
onFoo((view) => {
// fields come from the hook's `params` / `mutable` / `receiver`
});
} Grammar is checked at build. A hooks section without engine:hooks fails the build. Each descriptor must carry:
expose.ctx— required by the runtime (“nothing could subscribe”). For a plugin this does not mint actxmember; subscription staysEngine.hook(name)so a colliding name cannot clobber a built-in.shape— a closed thunk list (this_void,this_f32_i32_i32_i32,this_f32_i32_i64_i64,this_i64_i32_i64,this_i64_i64_i64). Unknown shapes fail the build.paramsarity must match the shape.validate— mandatory (inline on the target, or inherited from the named signature). A wrong detour address overwrites whatever prologue is actually there. Vtable targets still requirevalidate.prologue.bypassWith(optional) — must name acallsdescriptor in the same gamedata.
Until an operator lists your plugin id under engine:hooks in addons/s2script/configs/permissions.json, every declared hook resolves to null. Same default-deny as calls:
{ "engine:hooks": ["@me/store"] } 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. A new hook shape is a core change, not a plugin-gamedata field.
See also
@s2script/sdk/unsafe—Engine.call/Engine.hookexamples/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