# 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.

```jsonc
// 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.

```ts
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"`:

```ts
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:

```jsonc
"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:

```jsonc
{ "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:

```ts
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:

```bash
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`](https://s2script.com/docs/api/modules/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](https://s2script.com/docs/concepts/entities) — the `EntityRef` model these calls receive and return
