@s2script/sdk 0.30.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/entity.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * NO runtime code: the engine injects the implementation at load time.
4
4
  */
5
5
 
6
- import type { Vector } from "./math";
6
+ import type { Vector, QAngle } from "./math";
7
7
 
8
8
  /**
9
9
  * A host-liveness-gated handle to a live entity. `id` is a HOST-MINTED monotonic liveness
@@ -88,6 +88,14 @@ export declare class EntityRef {
88
88
  * at `finalOff` into a number[]. All in-core (raw pointers never cross); null if the root is stale or any hop
89
89
  * is null. */
90
90
  readFloatsChain(ptrOffs: number[], finalOff: number, count: number): number[] | null;
91
+ /** The `Vector` at `offset`, or null if the ref is stale. */
92
+ readVector(offset: number): Vector | null;
93
+ /** The `QAngle` at `offset`, or null if the ref is stale. */
94
+ readQAngle(offset: number): QAngle | null;
95
+ /** The `Vector` at the end of a pointer chain, or null on a stale ref or a null hop. */
96
+ readVectorChain(ptrOffs: number[], finalOff: number): Vector | null;
97
+ /** The `QAngle` at the end of a pointer chain, or null on a stale ref or a null hop. */
98
+ readQAngleChain(ptrOffs: number[], finalOff: number): QAngle | null;
91
99
  /** Follow a pointer chain (each an offset), then read a scalar at `finalOff`. null if the root is stale or any
92
100
  * hop is null. `readHandleVia` decodes a handle field → a liveness-gated EntityRef; vectors use readFloatsChain. */
93
101
  readInt32Via(pathOffs: number[], finalOff: number): number | null;
package/ext/s2ext.h ADDED
@@ -0,0 +1,155 @@
1
+ /* s2script native extension ABI, version 1.
2
+ *
3
+ * A native extension is a shared library in addons/s2script/extensions/ that adds natives to
4
+ * s2script without a core change, the way SourceMod extensions do. It never sees V8 or Rust:
5
+ * it registers plain C functions with a declared signature, and the host binds each one as a
6
+ * JavaScript function. A plugin that declares the extension in package.json imports it by name:
7
+ *
8
+ * "s2script": { "extensions": { "@community/fastmath": "^0.1.0" } }
9
+ * import { clamp } from "@community/fastmath";
10
+ *
11
+ * The rules:
12
+ *
13
+ * - Export two functions, s2ext_info and s2ext_init. The host calls s2ext_info first and refuses
14
+ * the extension (with a named reason, while the server keeps running) if its ABI version is
15
+ * not one the host speaks. Then it calls s2ext_init once, on the game thread, where the
16
+ * extension registers its natives.
17
+ * - Every native's C function takes an opaque receiver first, then its declared arguments:
18
+ * int32_t clamp(void* recv, int32_t v, int32_t lo, int32_t hi);
19
+ * The receiver is meaningless to the extension; it is there because it is the shape V8's fast
20
+ * API calls, which lets the host point optimised JavaScript straight at your function.
21
+ * - Natives run on the game thread. A native whose types are all numbers or booleans, and that
22
+ * does not set S2N_CAN_FAIL, is a fast native: optimised code calls it directly, so it must not
23
+ * block, must not call back into the host except log and schema_offset, and must not keep the
24
+ * receiver.
25
+ * - Extensions load at server start and are never unloaded.
26
+ * - Structs only grow by appending fields, and each carries its size, so an older or newer peer
27
+ * is detected without a version bump. A real break bumps S2EXT_ABI_VERSION.
28
+ * - Native code runs with full access, the same trust as a Metamod plugin. Engine access goes
29
+ * through the host services below, never through engine tables directly.
30
+ *
31
+ * Supported platform for ABI 1: x86-64 Linux (the System V calling convention).
32
+ */
33
+ #ifndef S2EXT_H
34
+ #define S2EXT_H
35
+
36
+ #include <stdint.h>
37
+ #ifndef __cplusplus
38
+ #include <stdbool.h>
39
+ #endif
40
+
41
+ #ifdef __cplusplus
42
+ extern "C" {
43
+ #endif
44
+
45
+ #define S2EXT_ABI_VERSION 1u
46
+
47
+ /* Argument and result types, and how each crosses.
48
+ *
49
+ * type C type JavaScript notes
50
+ * S2T_VOID void undefined result only
51
+ * S2T_BOOL bool boolean arguments convert as JS truthiness
52
+ * S2T_I32 int32_t number arguments convert as ToInt32
53
+ * S2T_U32 uint32_t number arguments convert as ToUint32
54
+ * S2T_F32 float number
55
+ * S2T_F64 double number
56
+ * S2T_STRING const char* string UTF-8, NUL-terminated. An argument is
57
+ * valid only during the call, and a string
58
+ * with an embedded NUL is refused. A result
59
+ * is copied at once and must stay valid until
60
+ * your next native returns; NULL gives null.
61
+ * S2T_ENTITY void* EntityRef argument only: the live entity's address,
62
+ * valid only during the call. A stale ref
63
+ * throws S2Error code "stale_entity" and
64
+ * your function is not called.
65
+ *
66
+ * Limits per native: at most 5 arguments of the integer class (bool, i32, u32, string, entity)
67
+ * and at most 8 of the float class (f32, f64), so every argument travels in a register.
68
+ * A native with a string or entity anywhere, or with S2N_CAN_FAIL, is called through the host's
69
+ * generic path rather than directly. That path costs roughly 100-150 ns per call, against
70
+ * under 10 ns for a fast native.
71
+ */
72
+ typedef enum S2Type {
73
+ S2T_VOID = 0,
74
+ S2T_BOOL = 1,
75
+ S2T_I32 = 2,
76
+ S2T_U32 = 3,
77
+ S2T_F32 = 4,
78
+ S2T_F64 = 5,
79
+ S2T_STRING = 6,
80
+ S2T_ENTITY = 7
81
+ } S2Type;
82
+
83
+ /* Native flags. */
84
+ enum {
85
+ /* The native may call host->fail. If it does, its result is discarded and the call throws an
86
+ * S2Error with the given code. */
87
+ S2N_CAN_FAIL = 1u
88
+ };
89
+
90
+ /* Log levels for S2HostApi.log. */
91
+ enum { S2LOG_INFO = 0, S2LOG_WARN = 1, S2LOG_ERROR = 2 };
92
+
93
+ /* What the host hands an extension. Grows only by appending fields: check `size` before using a
94
+ * field added after version 1. Every service takes the host pointer it was reached through. */
95
+ typedef struct S2HostApi {
96
+ uint32_t size;
97
+ uint32_t abi;
98
+ /* The runtime's version, e.g. "0.4.0". */
99
+ const char* host_version;
100
+ /* One line in the server console, prefixed with the extension's name. */
101
+ void (*log)(const struct S2HostApi* host, int32_t level, const char* message);
102
+ /* A networked field's byte offset in its class (Source 2 schema), or -1 if either name is
103
+ * unknown. Cached by the host; safe from a fast native. Game thread only. */
104
+ int32_t (*schema_offset)(const struct S2HostApi* host, const char* class_name, const char* field);
105
+ /* Inside a native registered with S2N_CAN_FAIL: make this call throw S2Error. `code` is a short
106
+ * lowercase identifier ([a-z0-9_], up to 32 bytes), `message` is free text. Ignored, with a
107
+ * warning, anywhere else. */
108
+ void (*fail)(const struct S2HostApi* host, const char* code, const char* message);
109
+ } S2HostApi;
110
+
111
+ /* One native, as s2ext_init registers it. */
112
+ typedef struct S2NativeDesc {
113
+ uint32_t size; /* sizeof(S2NativeDesc) */
114
+ const char* name; /* a JavaScript identifier, unique in this extension */
115
+ void* fn; /* ret fn(void* recv, args...) */
116
+ S2Type ret;
117
+ const S2Type* args;
118
+ uint32_t nargs;
119
+ uint32_t flags; /* S2N_* */
120
+ const char* doc; /* optional: a one-paragraph description, for the generated typings */
121
+ const char* const* arg_names;/* optional: nargs parameter names, for the generated typings */
122
+ } S2NativeDesc;
123
+
124
+ typedef struct S2Registrar S2Registrar;
125
+ typedef struct S2RegistrarApi {
126
+ uint32_t size;
127
+ /* Register one native. Returns 0, or nonzero if the descriptor is refused; the host has then
128
+ * recorded why, and the extension is refused as a whole once s2ext_init returns. Valid only
129
+ * during s2ext_init. */
130
+ int32_t (*native)(S2Registrar* reg, const S2NativeDesc* desc);
131
+ } S2RegistrarApi;
132
+
133
+ typedef struct S2ExtInfo {
134
+ uint32_t size; /* sizeof(S2ExtInfo) */
135
+ uint32_t abi; /* S2EXT_ABI_VERSION */
136
+ const char* name; /* the npm-style name plugins import, e.g. "@community/fastmath" */
137
+ const char* version; /* semver, e.g. "0.1.0" */
138
+ } S2ExtInfo;
139
+
140
+ #if defined(_WIN32)
141
+ #define S2EXT_EXPORT __declspec(dllexport)
142
+ #else
143
+ #define S2EXT_EXPORT __attribute__((visibility("default")))
144
+ #endif
145
+
146
+ /* Exported by the extension. s2ext_info must return a pointer that stays valid for the process.
147
+ * s2ext_init returns 0 on success; anything else refuses the extension. The host pointer stays
148
+ * valid for the process, so keep it for later service calls. */
149
+ S2EXT_EXPORT const S2ExtInfo* s2ext_info(void);
150
+ S2EXT_EXPORT int32_t s2ext_init(const S2HostApi* host, S2Registrar* reg, const S2RegistrarApi* api);
151
+
152
+ #ifdef __cplusplus
153
+ }
154
+ #endif
155
+ #endif
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s2script/sdk",
3
- "version": "0.30.2",
3
+ "version": "0.31.0",
4
4
  "license": "MIT OR Apache-2.0",
5
5
  "description": "s2script SDK — the builtin capability types (imported as @s2script/sdk/entity, …) plus the `s2s` CLI for building Source 2 / CS2 plugins.",
6
6
  "type": "module",
@@ -34,7 +34,8 @@
34
34
  "README.md",
35
35
  "LICENSE-MIT",
36
36
  "LICENSE-APACHE",
37
- "contracts"
37
+ "contracts",
38
+ "ext"
38
39
  ],
39
40
  "exports": {
40
41
  ".": {
package/unsafe.d.ts CHANGED
@@ -10,6 +10,92 @@
10
10
  * (see docs/superpowers/specs/2026-07-24-plugin-gamedata-design.md and ARCHITECTURE.md §2.0.7).
11
11
  */
12
12
 
13
+ import type { EntityRef } from "./entity";
14
+ import type { Vector, QAngle } from "./math";
15
+
16
+ declare const ptrBrand: unique symbol;
17
+
18
+ /**
19
+ * A raw address in the server process: what an engine call with a `ptr` return gives you, what
20
+ * {@link Ptr.from} makes from an entity, and what a `ptr` argument takes.
21
+ *
22
+ * UNSAFE. Nothing tracks what a `Ptr` points at. It answers typed reads and writes at a byte
23
+ * offset, follows pointer fields with {@link Ptr.deref}, and can be handed to any generated
24
+ * struct view, but a stale or wrong address reads garbage or crashes the server exactly as it
25
+ * would in a Metamod plugin. Only addresses that can never be valid (the null page, anything
26
+ * outside the user half of the address space) are refused, with an `S2Error` whose `code` is
27
+ * `"bad_address"`. Prefer entity handles and their schema fields, which are checked on every
28
+ * access; reach for `Ptr` when an engine structure has no other door.
29
+ *
30
+ * Do not keep a `Ptr` past the call or event that produced it unless you know the lifetime of the
31
+ * memory behind it.
32
+ *
33
+ * @example
34
+ * import { Ptr } from "@s2script/sdk/unsafe";
35
+ * const p = Ptr.from(pawn); // the entity's address, or null when it is stale
36
+ * if (p) console.log(p.readInt32(healthOffset));
37
+ */
38
+ export declare class Ptr {
39
+ private constructor();
40
+ private readonly [ptrBrand]: true;
41
+ /** The null pointer. Reads through it throw. */
42
+ static readonly NULL: Ptr;
43
+ /** A `Ptr` for an address, or an entity's current address (null once the entity is gone). */
44
+ static from(address: number | Ptr): Ptr;
45
+ static from(entity: EntityRef): Ptr | null;
46
+ /** The address as a number. Exact: every user-space address is below 2^47. */
47
+ readonly address: number;
48
+ isNull(): boolean;
49
+ /** This address plus `bytes`. */
50
+ add(bytes: number): Ptr;
51
+ equals(other: Ptr): boolean;
52
+ /** `0x…` */
53
+ toString(): string;
54
+ /** Hand this address to a view factory, such as a game package's struct view. */
55
+ as<T>(view: (ptr: Ptr) => T): T;
56
+ /** The pointer stored at `offset` (a pointer field), as a `Ptr`. `Ptr.NULL` when it holds null. */
57
+ deref(offset?: number): Ptr;
58
+ readPtr(offset: number): Ptr;
59
+ writePtr(offset: number, value: Ptr): true;
60
+ readInt8(offset: number): number;
61
+ readUInt8(offset: number): number;
62
+ readInt16(offset: number): number;
63
+ readUInt16(offset: number): number;
64
+ readInt32(offset: number): number;
65
+ readUInt32(offset: number): number;
66
+ readFloat32(offset: number): number;
67
+ readFloat64(offset: number): number;
68
+ readBool(offset: number): boolean;
69
+ /** A 64-bit integer, as a decimal string (a JS number cannot hold every value). */
70
+ readInt64(offset: number): string;
71
+ readUInt64(offset: number): string;
72
+ writeInt8(offset: number, value: number): true;
73
+ writeUInt8(offset: number, value: number): true;
74
+ writeInt16(offset: number, value: number): true;
75
+ writeUInt16(offset: number, value: number): true;
76
+ writeInt32(offset: number, value: number): true;
77
+ writeUInt32(offset: number, value: number): true;
78
+ writeFloat32(offset: number, value: number): true;
79
+ writeFloat64(offset: number, value: number): true;
80
+ writeBool(offset: number, value: boolean): true;
81
+ /** The NUL-terminated string at `offset`, reading at most `maxLen` bytes. */
82
+ readString(offset: number, maxLen: number): string;
83
+ /** Write `value` into a `maxLen`-byte buffer, truncated so it always ends in a NUL. */
84
+ writeString(offset: number, maxLen: number, value: string): boolean;
85
+ /** `count` consecutive 32-bit floats (a vector, an angle). */
86
+ readFloats(offset: number, count: number): number[];
87
+ /** The `Vector` at `offset`. */
88
+ readVector(offset: number): Vector;
89
+ /** The `QAngle` at `offset`. */
90
+ readQAngle(offset: number): QAngle;
91
+ /** The `Vector` at the end of a pointer chain, or null on a null hop. */
92
+ readVectorChain(ptrOffs: number[], finalOff: number): Vector | null;
93
+ /** The `QAngle` at the end of a pointer chain, or null on a null hop. */
94
+ readQAngleChain(ptrOffs: number[], finalOff: number): QAngle | null;
95
+ /** A `CEntityHandle` field, resolved through the entity books: null when it names no live entity. */
96
+ readHandle(offset: number): EntityRef | null;
97
+ }
98
+
13
99
  /**
14
100
  * The calls this plugin declares. The base interface is EMPTY — `s2s build` generates
15
101
  * `.s2script/gamedata.d.ts`, which augments this from your gamedata's `calls` section.
@@ -59,8 +145,64 @@ export interface FunctionSubscription extends Disposable {
59
145
  * const onFoo = Engine.hook("onFoo");
60
146
  * if (onFoo) onFoo((view) => { view.reason = 0; });
61
147
  */
148
+ /** A target validator, as in `gamedata/functions.jsonc`. */
149
+ export interface FunctionValidator {
150
+ prologue?: string;
151
+ 'string-xref'?: { at: number; dispOff: number; instrLen: number; expect: string };
152
+ 'vtable-member'?: string;
153
+ }
154
+ /** A function located by byte signature. */
155
+ export interface SignatureTarget {
156
+ kind?: 'signature';
157
+ module: string;
158
+ pattern: string;
159
+ validate: FunctionValidator;
160
+ targetValidate?: FunctionValidator;
161
+ }
162
+ /** A function located by vtable slot. */
163
+ export interface VirtualTarget {
164
+ kind?: 'virtual';
165
+ module: string;
166
+ class: string;
167
+ index: number;
168
+ validate: FunctionValidator;
169
+ }
170
+ /** A parameter or return type. `ptr` is a raw address, a {@link Ptr} (or null) in JavaScript. */
171
+ export type FunctionValueType = 'bool' | 'i32' | 'u32' | 'i64' | 'u64' | 'f32' | 'f64' | 'entity' | 'entity?' | 'string' | 'vector' | 'ptr';
172
+ /** One engine function, written exactly as an entry of `gamedata/functions.jsonc`. */
173
+ export interface FunctionDeclaration<T> {
174
+ target: T | { ref: string };
175
+ receiver?: { type: 'none' | 'entity' };
176
+ parameters?: readonly { name: string; type: FunctionValueType; ownership?: 'callee-borrowed' | 'callee-retained' | 'native-observed'; mutable?: 'pre' }[];
177
+ returns?: FunctionValueType | 'void' | { type: 'string' | 'vector'; ownership: 'caller-borrowed' | 'native-observed' };
178
+ surfaces?: readonly ('call' | 'pre' | 'post')[];
179
+ suppression?: 'generic' | 'none';
180
+ requirement?: 'optional' | 'required';
181
+ resolve?: 'direct' | 'ctor-body-xref' | 'lea-disp' | 'validated-call';
182
+ }
183
+
62
184
  export declare const Engine: {
63
185
  function<K extends keyof EngineFunctions>(name: K): EngineFunctions[K];
186
+ /**
187
+ * Declare an engine function inline, by byte signature, and get its binding: the same as
188
+ * writing the declaration in `gamedata/functions.jsonc` and calling {@link Engine.function}.
189
+ *
190
+ * `s2s build` copies the declaration into the plugin's manifest, so it is validated, listed and
191
+ * checked after game updates like any other. That is why both arguments must be literals; a
192
+ * computed declaration fails the build. The binding's types come from the build, so run it once
193
+ * after adding a declaration.
194
+ *
195
+ * @example
196
+ * const ignite = Engine.fromSignature("ignite", {
197
+ * target: { module: "libserver.so", pattern: "55 48 89 E5 ...", validate: { prologue: "55 48 89 E5" } },
198
+ * receiver: { type: "entity" },
199
+ * parameters: [{ name: "seconds", type: "f32" }],
200
+ * surfaces: ["call"],
201
+ * });
202
+ */
203
+ fromSignature<K extends keyof EngineFunctions>(name: K, declaration: FunctionDeclaration<SignatureTarget>): EngineFunctions[K];
204
+ /** {@link Engine.fromSignature} for a function located by vtable slot. */
205
+ fromVirtual<K extends keyof EngineFunctions>(name: K, declaration: FunctionDeclaration<VirtualTarget>): EngineFunctions[K];
64
206
  /**
65
207
  * The declared call, or `null` when its descriptor failed a load-time gate (signature miss,
66
208
  * validator rejection, slot outside `.text`, missing platform entry, or the plugin is not