Search docs
Search guides, modules and symbols
Engine calls

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 functions close that. You declare the function in gamedata/functions.jsonc and bind it with Engine.function("localName") from @s2script/sdk/unsafe. s2s build discovers that file, generates local types, and derives engine:calls / engine:hooks in the archive manifest from the surfaces you list. There is no handwritten s2script.gamedata field, permission list, or generated-file include.

This is the unsafe module for a reason. Read the safety model below before shipping one.

The public Engine.function runtime is merged into s2script main. npm may lag until the SDK changesets release ships — this is the API authors will use once that version is on the registry.

Declare it#

One file, gamedata/functions.jsonc, schemaVersion 2. s2s build looks there; you do not point the manifest at it.

// gamedata/functions.jsonc
{
	"schemaVersion": 2,
	"functions": {
		"commitSuicide": {
			"target": {
				"module": "libserver.so",
				"pattern": "55 48 89 E5 41 55 41 89 F5 41 54 41 89 D4 53 48 8D 15 ? ? ? ? 48 89 FB 48 81 EC ? ? ? ?",
				"validate": {
					"prologue": "55 48 89 E5 41 55 41 89 F5 41 54 41 89 D4",
					"vtable-member": "CBasePlayerPawn"
				}
			},
			"receiver": { "type": "entity" },
			"parameters": [
				{ "name": "explode", "type": "bool" },
				{ "name": "force", "type": "bool", "mutable": "pre" }
			],
			"returns": "void",
			"surfaces": ["call", "pre", "post"]
		}
	}
}

That descriptor is the monorepo’s examples/engine-function-demo recipe for CBasePlayerPawn_CommitSuicide. Re-audit the pattern against your installed libserver.so after a game update — do not treat the bytes as a pin that travels.

surfaces on each function drives which methods exist on the binding and which permissions the archive derives:

  • call — .call(...) and engine:calls
  • pre — .onPre(...) and engine:hooks
  • post — .onPost(...) and engine:hooks

Omit surfaces and the default is ["call"]. A mutable: "pre" parameter requires a pre surface; that field is writable on the PRE view and readonly otherwise. POST views are always readonly, plus returnValue.

A virtual target uses "kind": "virtual" with class, index, and a required validate.prologue — a vtable index is never trusted bare. Named targets can be shared with { "ref": "…" } on a function.

Bind it#

s2s build generates .s2script/engine-functions.d.ts as a typecheck root — you do not add it to tsconfig.json include. The binding is never null. Optional functions (the default) are a discriminant: check available before calling. requirement: "required" fails preparation as a whole — a failed reload leaves the running plugin untouched — and types the binding as always-available.

import { command, HookResult } from '@s2script/sdk';
import { Engine } from '@s2script/sdk/unsafe';

export function OnPluginStart(): void {
	const commitSuicide = Engine.function('commitSuicide');
	console.log(JSON.stringify(commitSuicide.status));

	if (!commitSuicide.available) {
		console.log(`unavailable: ${commitSuicide.status.reason}`);
		return;
	}

	const first = commitSuicide.onPre((view) => {
		view.force = true;
		return HookResult.Changed;
	});
	first.dispose();

	commitSuicide.onPre((view) => {
		view.force = true;
		return HookResult.Changed;
	});
	commitSuicide.onPost((view) => {
		console.log(`POST explode=${view.explode} force=${view.force}`);
	});

	command('sm_ef_status', (cmd) => {
		cmd.reply(JSON.stringify(commitSuicide.status));
		return HookResult.Handled;
	});
}

status is immutable and structured: canonicalId, availability ("available" | "unavailable"), reason, hookObservation ("not-requested" | "pending" | "active" | "failed"), and provenance hashes / override receipts. An undeclared name is a TS2345; a wrong .call arity is a TS2554 — the same gate that checks the rest of your plugin.

Generated call parameters are positional (arg0_explode, arg1_force, …). PRE/POST view fields use the authored names, with mutability from mutable: "pre":

call(self: EntityRef, arg0_explode: boolean, arg1_force: boolean): void;

interface CommitSuicidePreView {
	readonly self: EntityRef;
	readonly explode: boolean;
	force: boolean; // mutable: "pre"
}

.call, .onPre, and .onPost exist only when that surface was declared. Subscriptions are disposable (dispose(): boolean). onPre({ observeOnly: true }, handler) takes a Readonly view and only a void/undefined return — a HookResult action is a type error.

A plugin that calls a function it also hooked does not re-enter its own PRE/POST for that invocation (selfCall: "bypass-own-hooks"). Nested invocations of the same function still skip a plugin that is already inside that target.

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": "virtual" 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": "virtual",
	"module": "libserver.so",
	"class": "CCSPlayer_ItemServices",
	"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. The demo’s staleEntryProbe uses the same pattern with a deliberately wrong prologue so that failure leaves commitSuicide available. status.reason is that named reason at runtime, so a plugin can tell an operator “this build moved the signature” instead of silently doing nothing.

The operator decides#

Derived permissions in the archive are necessary but not sufficient. Until an operator lists your plugin id in addons/s2script/configs/permissions.json, declared call and hook surfaces resolve unavailable:

{ "engine:calls": ["@demo/engine-function"], "engine:hooks": ["@demo/engine-function"] }

It’s exact-match and default-deny. A plugin that isn’t listed still loads and runs normally — only its declared functions are degraded — so an operator’s oversight can’t take a server down. An operator who granted outbound calls has not granted inbound patches; the two lists are separate.

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 or bool the native body consults. The call returns as if it ran; the engine no-ops.
  • 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.0 arrives as 0.0f — no crash, no diagnostic, just a no-op.

Both look like success from the caller’s side. So verify the engine actually acted, rather than trusting that the call returned. The demo reads pawn health before and after .call, and reports alive true -> false only if that change occurred:

const before = pawn.health;
commitSuicide.call(pawn.ref, false, true);
const after = Player.fromSlot(slot)?.pawn?.health ?? null;
// before > 0 && after === 0 → the engine really did something

The demo’s lethal path is server console only (sm_ef_suicide_bot), refuses humans, and never runs at load. Building or registering hooks does not invoke the call.

You own the treadmill#

Your functions.jsonc is yours to maintain. When CS2 updates, that byte pattern stops matching, the function 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.

PRE and POST#

PRE handlers may mutate mutable: "pre" fields and return HookResult-style Continue / Changed / Handled / Stop (or void). suppression defaults to "generic" when a pre surface is listed — that is what admits Handled / Stop. "suppression": "none" only types Continue / Changed.

For a non-void return with generic suppression, PRE cannot return a bare Handled / Stop. Return { action: HookResult.Handled | HookResult.Stop, returnValue } so the substitute is typed. POST is observe-only: returnValue is on the view, and assigning it is a type error.

Game-package adapters with override authority can carry a PRE proposal to a forced POST (frame.proposedReturn / overrideReturn). That is a native/trusted facility, not the ordinary plugin path.

Game packages#

Game packages own sealed recipes and bind them through the same engine-function service. Ordinary plugins declare their own gamedata/functions.jsonc. Trusted artifacts, activate_trusted, hidden native-only positions, and string-indirect are generic facilities for those game packages — not something a community plugin authors in schemaVersion 2.

Migrating v1 plugins#

Existing plugins that still have s2script.gamedata plus a handwritten permissions list convert with:

npx @s2script/sdk migrate engine-functions [plugin-dir]
# same command: s2s migrate engine-functions [plugin-dir]

The command analyzes the v1 gamedata and source references, reports ambiguities, and writes gamedata/functions.jsonc only when the declared contract converts without loss. It refuses an existing destination unless you pass --force. A successful conversion does not verify that the v1 declaration listed every native argument, or that the resulting call works on the current game build.

It does not rewrite Engine.call / Engine.hook source, and it does not provide runtime compatibility facades for old source. The JSON report lists files actually changed and the package/source edits you must make by hand — including removing the old s2script.gamedata field and authored engine:calls / engine:hooks permissions when adopting the v2 file. V1 entity hooks are refused: their callbacks may receive a null receiver, which the v2 non-null receiver projection cannot preserve.

v1 (historical)#

<name>.gamedata.jsonc plus handwritten s2script.gamedata / s2script.permissions, Engine.call, and Engine.hook are the old authoring path. They remain on the @s2script/sdk/unsafe types so existing archives typecheck; do not start new plugins that way. examples/engine-call-demo is a v1 archive only — its Ignite pattern and four-scalar ABI are stale on current CS2.

Limits#

The receiver is an entity ("receiver": { "type": "entity" }) or none (omit the field, or "type": "none") for a static engine function with no this. There is no receiver.via hop. Generated .call takes a leading self: EntityRef only when the receiver is an entity.

Arguments come from a closed set — bool, i32, u32, i64, u64, f32, f64, entity, entity?, string, vector — and returns from those plus void. Copied string / vector parameters and returns require an explicit ownership field; the public v2 grammar rejects string-indirect.

At most 32 authored parameters and 33 CIF arguments (receiver plus parameters), with a 256-byte stack-copy bound. Six general-purpose registers and eight SSE registers are the SysV register counts rather than a limit on the call — arguments past those spill to the stack. Exceeding either bound fails the build.

No struct-by-value, no out-params. A new projection or adapter policy is a core change, not a plugin-gamedata field.

See also#

  • @s2script/sdk/unsafe — Engine.function (calls + PRE/POST)
  • examples/engine-function-demo in the monorepo — a complete v2 plugin (CommitSuicide with PRE/POST, plus a descriptor that is meant to fail)
  • examples/engine-call-demo — historical v1 Ignite archive only; do not treat it as current authoring
  • Entities — the EntityRef model these calls receive and return