@amxts/core 0.1.0 → 0.2.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.
Files changed (74) hide show
  1. package/README.md +18 -23
  2. package/README.ru.md +19 -22
  3. package/as/amxts.d.ts +63 -3
  4. package/as/constants.ts +684 -116
  5. package/as/entities.ts +1905 -1364
  6. package/as/events.ts +1694 -905
  7. package/as/facade.ts +1240 -220
  8. package/as/fetch.ts +1519 -0
  9. package/as/flags.ts +159 -145
  10. package/as/hlds.ts +1071 -0
  11. package/as/hooks.ts +5226 -5379
  12. package/as/kit.ts +15 -5
  13. package/as/lib/check.ts +1 -1
  14. package/as/natives.ts +2658 -2499
  15. package/as/remote.ts +39 -15
  16. package/as/tsconfig.json +6 -2
  17. package/as/vector.ts +12 -9
  18. package/includes/order.txt +22 -29
  19. package/includes/sources.json +0 -8
  20. package/node_modules/assemblyscript/std/assembly/index.d.ts +20 -11
  21. package/package.json +29 -7
  22. package/runtime/deps/assemblyscript/dist/asc.js +86 -16
  23. package/runtime/deps/assemblyscript/dist/assemblyscript.js +29 -29
  24. package/runtime/licenses/MPL-2.0.txt +373 -0
  25. package/runtime/licenses/README.md +5 -1
  26. package/runtime/licenses/curl-COPYING +22 -0
  27. package/runtime/licenses/libssh2-COPYING +43 -0
  28. package/runtime/licenses/mbedTLS-LICENSE +553 -0
  29. package/runtime/natives.txt +108 -105
  30. package/runtime/patches/assemblyscript-0.28.20-amxts.patch +752 -296
  31. package/runtime/patches/wamr-2.4.5-amxts.patch +148 -0
  32. package/scripts/apply-docs.ts +2 -2
  33. package/scripts/auto-imports.ts +2 -2
  34. package/scripts/build-identity.ts +115 -0
  35. package/scripts/build-wasm.ts +101 -50
  36. package/scripts/check.ts +17 -3
  37. package/scripts/client-messages.ts +246 -0
  38. package/scripts/compile-pool.ts +128 -0
  39. package/scripts/compile-worker.ts +23 -0
  40. package/scripts/compile.ts +156 -59
  41. package/scripts/docker-server.ts +13 -3
  42. package/scripts/docs/as/amxts.ts +86 -0
  43. package/scripts/docs/as/facade.ts +688 -188
  44. package/scripts/docs/as/fetch.ts +805 -0
  45. package/scripts/docs/entities.ts +43 -144
  46. package/scripts/docs/events.ts +15 -26
  47. package/scripts/docs/game.ts +68 -22
  48. package/scripts/docs/messages.ts +330 -16
  49. package/scripts/editor-api.ts +101 -0
  50. package/scripts/hlds-events.ts +370 -0
  51. package/scripts/includes.ts +12 -10
  52. package/scripts/player-fields.ts +16 -15
  53. package/scripts/prebuilt.ts +65 -23
  54. package/scripts/prepare.ts +39 -28
  55. package/scripts/project.ts +120 -20
  56. package/scripts/reapi-events.ts +98 -0
  57. package/scripts/shared-modules.ts +85 -23
  58. package/scripts/source-map.ts +242 -0
  59. package/scripts/system.ts +1 -1
  60. package/scripts/typed-commands.ts +263 -0
  61. package/scripts/typed-configs.ts +23 -12
  62. package/scripts/ui.ts +3 -1
  63. package/scripts/upgrade-menus.ts +274 -0
  64. package/scripts/upgrade-names.ts +384 -0
  65. package/scripts/upgrade.ts +830 -0
  66. package/src/cli-api.mjs +37 -4
  67. package/src/system.mjs +74 -3
  68. package/src/testing/compile.ts +8 -5
  69. package/src/testing/natives.ts +199 -5
  70. package/src/testing/network.ts +531 -0
  71. package/src/testing/server.ts +565 -47
  72. package/src/testing/tables.ts +25 -0
  73. package/as/modules/http.ts +0 -184
  74. package/scripts/docs/as/modules/http.ts +0 -66
@@ -16,7 +16,8 @@ import { compile } from './compile';
16
16
  import { Coroutines, nextTaskId } from './coroutines';
17
17
  import { installKitFor } from './kits';
18
18
  import { bitsFloat, floatBits, Memory, utf8Fit } from './memory';
19
- import { NATIVES, WEAPON_NAMES } from './natives';
19
+ import { fieldCell, NATIVES, setFieldCell, WEAPON_NAMES } from './natives';
20
+ import { FakeNetwork } from './network';
20
21
  import { constant, tables } from './tables';
21
22
 
22
23
  declare const WebAssembly: any;
@@ -64,6 +65,8 @@ interface Handler {
64
65
  shape: number;
65
66
  /** A closure's number: fn is then the plugin's dispatcher, called with it first (Handler.tag in module.cpp). */
66
67
  tag?: number;
68
+ /** on_cell's filter: the handler hears the forward only when this argument is `value`. */
69
+ where?: { arg: number; value: number };
67
70
  }
68
71
 
69
72
  /** A host public standing in for a handler: `__amxts_cb<index>`. */
@@ -72,6 +75,12 @@ interface Slot extends Handler {
72
75
  /** What the call answers when the handler says nothing. */
73
76
  fallback: number;
74
77
  key: string;
78
+ /**
79
+ * Switched off while what it delivers has no listener - by the plugin
80
+ * (slot_on), or a hook by DisableHookChain and DisableHamForward: a call
81
+ * answers the fallback without reaching the plugin.
82
+ */
83
+ off?: boolean;
75
84
  }
76
85
 
77
86
  interface Task {
@@ -94,7 +103,7 @@ interface Cvar {
94
103
  export interface Chain {
95
104
  shape: HookShape;
96
105
  answer: Value;
97
- args: Value[];
106
+ args: HookArg[];
98
107
  /** SetHookChainReturn ran: reapi lets a chain that answers be stopped only then. */
99
108
  answered?: boolean;
100
109
  }
@@ -106,9 +115,12 @@ export interface HookResult {
106
115
  /** The chain's answer after every listener: a number, a boolean or text, by the chain. */
107
116
  result: Value;
108
117
  /** The arguments as the listeners left them (`event.damage = 10`). */
109
- args: Value[];
118
+ args: HookArg[];
110
119
  }
111
120
 
121
+ /** A hookchain's argument: a number, a boolean, text, or a vector as `[x, y, z]`. */
122
+ export type HookArg = Value | number[];
123
+
112
124
  /** A user message a plugin sent: ScreenFade, StatusIcon, ... */
113
125
  export interface UserMessage {
114
126
  name: string;
@@ -129,6 +141,36 @@ export interface SentForward {
129
141
  args: (Value | number[])[];
130
142
  }
131
143
 
144
+ /** A menu of AMX Mod X's own, made by menu_create, as the fake keeps it. */
145
+ export interface FakeMenu {
146
+ id: number;
147
+ title: string;
148
+ /** The public menu_create was given: called with (player, menu, item). */
149
+ handler: Slot;
150
+ /** Each item's name and its callback's number from menu_makecallback, or -1. */
151
+ items: { name: string; callback: number }[];
152
+ /** MPROP_PERPAGE: `0` is one page, without Back and More. */
153
+ perPage: number;
154
+ /** MPROP_EXIT is not MEXIT_NEVER. */
155
+ exit: boolean;
156
+ back: string;
157
+ next: string;
158
+ exitName: string;
159
+ numberColor: string;
160
+ }
161
+
162
+ /** A menu of AMX Mod X's own on a player's screen, as menu_display drew it. */
163
+ export interface MenuScreen {
164
+ menu: number;
165
+ page: number;
166
+ /** What he sees, with the game's colour codes: `\r1.\w Armor`. */
167
+ text: string;
168
+ /** The keys that answer: 1-9 and 0. */
169
+ keys: number[];
170
+ /** The keys of the items drawn grey, which do nothing. */
171
+ disabled: number[];
172
+ }
173
+
132
174
  /** One line a player was shown. */
133
175
  export interface Message {
134
176
  variant: 'chat' | 'center' | 'console' | 'notify' | 'hud';
@@ -147,12 +189,14 @@ export interface ServerOptions {
147
189
  /** Files in the game folder before the plugin loads, by path: `{ "addons/amxmodx/configs/x.ini": "..." }`. */
148
190
  files?: Record<string, string>;
149
191
  /**
150
- * The system the server runs on, as `~/os` finds it: "win32" puts the amxts
192
+ * The system the server runs on, as `@amxts/core/os` finds it: "win32" puts the amxts
151
193
  * module's amxts_amxx.dll in the modules folder. "linux" unless said.
152
194
  */
153
195
  platform?: 'win32' | 'linux';
154
196
  /** The server's time zone, what Date's local getters read: "Asia/Yerevan". This machine's unless said. */
155
197
  timeZone?: string;
198
+ /** Whether reapi finds Reunion, so a player's `authType`, `protocol` and `authKey` are his own. Not unless said. */
199
+ reunion?: boolean;
156
200
  }
157
201
 
158
202
  export type TeamName = 'UNASSIGNED' | 'TERRORIST' | 'CT' | 'SPECTATOR';
@@ -165,8 +209,15 @@ export interface JoinOptions {
165
209
  armor?: number;
166
210
  alive?: boolean;
167
211
  bot?: boolean;
168
- authid?: string;
212
+ /** His SteamID: "STEAM_0:0:<id>" unless said, "BOT" for a bot. */
213
+ steamId?: string;
169
214
  ip?: string;
215
+ /** How Reunion says his game proved who he is, by its name in the player's API: "steam" unless said. */
216
+ authType?: string;
217
+ /** His game's protocol, as Reunion says: 48 unless said. */
218
+ protocol?: number;
219
+ /** The key Reunion read from his game: "" unless said. */
220
+ authKey?: string;
170
221
  /** Admin flags as users.ini writes them: "abcdefghijklmnopqrstu". "z" - a plain user - unless said. */
171
222
  flags?: string;
172
223
  origin?: number[];
@@ -176,11 +227,16 @@ export interface JoinOptions {
176
227
 
177
228
  const SHAPE_NARROW = 0;
178
229
  const SHAPE_WIDE = 1;
230
+ /** A slot taken back after a reload: SLOT_REUSED in module.cpp. */
231
+ const SLOT_REUSED = 0x10000;
232
+ /** A request that tells an owner a plugin which called it stopped: RPC_GONE in module.cpp, KIND_GONE in as/remote.ts. */
233
+ const RPC_GONE = 2;
179
234
  const PLUGIN_HANDLED = 1;
180
235
  const HC_SUPERCEDE = 1;
181
236
  const HC_BREAK = 2;
182
- // A Ham Sandwich hook's answer that blocks the game's function.
237
+ // A Ham Sandwich hook's answer that blocks the game's function, and fakemeta's.
183
238
  const HAM_SUPERCEDE = 4;
239
+ const FMRES_SUPERCEDE = 4;
184
240
  // A message argument's types, as get_msg_argtype answers.
185
241
  const ARG_BYTE = 1;
186
242
  const ARG_COORD = 6;
@@ -266,8 +322,11 @@ export class FakePlayer extends FakeEntity {
266
322
  connected = true;
267
323
  alive: boolean;
268
324
  readonly bot: boolean;
269
- readonly authid: string;
325
+ readonly steamId: string;
270
326
  readonly ip: string;
327
+ readonly authType: string;
328
+ readonly protocol: number;
329
+ readonly authKey: string;
271
330
  /** Admin flags as bits: ADMIN_* . */
272
331
  flags: number;
273
332
  /** SPEAK_* flags: `muted` is SPEAK_MUTED. */
@@ -280,14 +339,22 @@ export class FakePlayer extends FakeEntity {
280
339
  readonly commands: string[] = [];
281
340
  /** His userinfo keys - `lang` says his language. */
282
341
  readonly info = new Map<string, string>();
342
+ /** The menu of AMX Mod X's own on his screen (menu_display), or null; `menuselect <key>` answers it. */
343
+ menu: MenuScreen | null = null;
344
+ /** His userid, the number `#12` names him by in a command: the server counts them up as players come. */
345
+ readonly userid: number;
283
346
 
284
347
  constructor(server: FakeServer, id: number, public name: string, options: JoinOptions) {
285
348
  super(server, id, 'player');
286
349
  this.alive = options.alive ?? true;
287
350
  this.bot = options.bot ?? false;
288
- this.authid = options.authid ?? (this.bot ? 'BOT' : `STEAM_0:0:${id}`);
351
+ this.steamId = options.steamId ?? (this.bot ? 'BOT' : `STEAM_0:0:${id}`);
289
352
  this.ip = options.ip ?? `127.0.0.${id}:27005`;
353
+ this.authType = options.authType ?? 'steam';
354
+ this.protocol = options.protocol ?? 48;
355
+ this.authKey = options.authKey ?? '';
290
356
  this.flags = flagBits(options.flags ?? 'z');
357
+ this.userid = server.nextUserid();
291
358
  this.team = options.team ?? 'CT';
292
359
  this.health = options.health ?? 100;
293
360
  this.armor = options.armor ?? 0;
@@ -408,9 +475,14 @@ export class FakePlayer extends FakeEntity {
408
475
  return false;
409
476
  }
410
477
 
411
- /** A console command, split the way the engine splits it: `amx_slap "Some One" 5`. True if handled. */
478
+ /**
479
+ * A console command, split the way the engine splits it: `amx_slap "Some One" 5`.
480
+ * `menuselect <key>` goes to his menu first, as AMX Mod X takes it. True if handled.
481
+ */
412
482
  command(line: string): boolean {
413
- return this.server.clientCommand(this, splitCommand(line));
483
+ const argv = splitCommand(line);
484
+ if (argv[0] === 'menuselect' && this.menu && this.server.selectMenu(this, Number(argv[1]))) return true;
485
+ return this.server.clientCommand(this, argv);
414
486
  }
415
487
 
416
488
  /** Leaves the server: the disconnect events, then the slot is free. */
@@ -452,12 +524,34 @@ function splitCommand(line: string): string[] {
452
524
  return [...line.matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2]);
453
525
  }
454
526
 
527
+ /**
528
+ * A log line split as AMX Mod X splits it for read_logargv: a quoted part
529
+ * and a part in brackets each one argument, the text between them another.
530
+ */
531
+ export function splitLog(line: string): string[] {
532
+ // The spaces between two parts are none.
533
+ return [...line.matchAll(/"([^"]*)"|\(([^)]*)\)|([^"(]+)/g)]
534
+ .filter(m => m[3] === undefined || m[3].trim() !== '')
535
+ .map(m => m[1] ?? m[2] ?? m[3].trim());
536
+ }
537
+
455
538
  /** A loaded plugin: its instance and its memory. */
539
+ /** The runs handed out so far: each plugin loaded is a new one. */
540
+ let runs = 0;
541
+
456
542
  export class PluginInstance {
457
543
  instance: any;
458
544
  memory!: Memory;
459
545
  /** What plugin() said about it. */
460
546
  info = { name: '', version: '', author: '', description: '' };
547
+ /** Taken off the server (FakeServer.unload): its slots answer nothing until the same file takes them back. */
548
+ unloaded = false;
549
+ /** This run of the plugin, as module.cpp's Plugin.run: what the other side of a shared module's call knows it by. */
550
+ readonly run = ++runs;
551
+ /** The shared modules it calls (Plugin.uses): their owners hear when it is unloaded. */
552
+ readonly uses: string[] = [];
553
+ /** It called back a function of a plugin unloaded since, and was told so: once. */
554
+ toldGone = false;
461
555
 
462
556
  /** The coroutine scheduler, for a plugin that awaits; null for one that does not. */
463
557
  readonly coroutines: Coroutines | null;
@@ -492,7 +586,7 @@ export class PluginInstance {
492
586
 
493
587
  if (this.server.hasNative(name)) return (...args: number[]) => this.server.callNativeImpl(this, name, args);
494
588
 
495
- // Another plugin's `export function`, called through ~/natives as AMX
589
+ // Another plugin's `export function`, called through @amxts/core/natives as AMX
496
590
  // Mod X would route it; whether one exports it is known only at the call.
497
591
  return (...cells: number[]) => {
498
592
  if (this.server.exported.has(name)) return this.server.callFromPlugin(this, name, cells);
@@ -515,6 +609,8 @@ export class FakeServer {
515
609
  readonly map: string;
516
610
  readonly maxPlayers: number;
517
611
  readonly modules: Set<string>;
612
+ /** Whether reapi finds Reunion (has_reunion). */
613
+ readonly reunion: boolean;
518
614
  /** Milliseconds since the map started; advance() moves it. */
519
615
  time = 0;
520
616
  /** Date.now() when the map started. */
@@ -575,6 +671,8 @@ export class FakeServer {
575
671
 
576
672
  /** The game rules' members a plugin set (set_member_game), by member. */
577
673
  readonly rules = new Map<number, number>();
674
+ /** The members plugins asked member_slot for, by slot: each one's reapi constant. */
675
+ private readonly memberSlots: number[] = [];
578
676
  /** Rounds a plugin ended with rg_round_end, in order. */
579
677
  readonly roundEnds: { status: number; event: number; delay: number; message: string; sound: string; trigger: boolean }[] = [];
580
678
  /** Sounds played: from an entity (emit_sound, rh_emit_sound2), or to a player alone (SendAudio, rg_send_audio). */
@@ -610,8 +708,37 @@ export class FakeServer {
610
708
  hookedMessage: { args: (number | string)[]; types: number[] } | null = null;
611
709
  /** register_touch's callbacks. */
612
710
  readonly touches: { touched: string; toucher: string; slot: Slot }[] = [];
711
+ /** register_logevent's callbacks: the number of arguments a line has, and the filters it passes. @internal */
712
+ readonly logEvents: { argc: number; filters: string[]; slot: Slot }[] = [];
713
+ /** The log line logevent callbacks are reading, as AMX Mod X splits it. @internal */
714
+ logArgs: string[] = [];
715
+ /** That line whole, as read_logdata reads it. @internal */
716
+ logLine = '';
717
+ /** register_event's callbacks, by the message's name, with their conditions ("1=0"). @internal */
718
+ readonly messageEvents: { name: string; conditions: string[]; slot: Slot }[] = [];
719
+ /** register_forward's callbacks, by `<FM_* number>:pre|post`. */
720
+ readonly fakemetaForwards = new Map<string, Slot[]>();
721
+ /** What a fakemeta callback answered with forward_return, and what get_orig_retval reads. @internal */
722
+ forwardAnswer: number | string | null = null;
723
+ origRetval = 0;
724
+ /** The engine's EngFunc_SetClientListening calls plugins made: listener, sender, whether he hears. */
725
+ readonly listening: number[][] = [];
726
+ /** The engine's and the game's functions plugins called through engfunc and dllfunc, each as a line: `TraceLine 1,2,3 4,5,6 1 7 0`. */
727
+ readonly engineCalls: string[] = [];
613
728
  /** query_client_cvar's questions, waiting for answerCvar(). */
614
729
  readonly cvarQueries: { player: number; cvar: string; slot: Slot }[] = [];
730
+ /** The menus menu_create made and menu_destroy has not taken away, by id. */
731
+ readonly menus = new Map<number, FakeMenu>();
732
+ /** menu_makecallback's publics, by the number it gave. @internal */
733
+ readonly menuCallbacks: Slot[] = [];
734
+ private menuIds = 0;
735
+ private userids = 1;
736
+
737
+ /** A userid for a player who comes: one more than the last. @internal */
738
+ nextUserid(): number {
739
+ return this.userids++;
740
+ }
741
+
615
742
  /** What precache_model and precache_sound were asked for, in order: the index is the place + 1. */
616
743
  readonly precached: string[] = [];
617
744
  /**
@@ -623,7 +750,7 @@ export class FakeServer {
623
750
  /** Which of those texts are lists of player ids ("3,5"), by `slot:key`: a player who leaves goes from them. */
624
751
  readonly playerLists = new Set<string>();
625
752
  /**
626
- * The playerchange listeners, as the module keeps them: per plugin, the
753
+ * The playerChange listeners, as the module keeps them: per plugin, the
627
754
  * fields it hears ("" for every one) and the trampoline that hands a change
628
755
  * to its listeners.
629
756
  */
@@ -640,13 +767,15 @@ export class FakeServer {
640
767
  private outcome = 0;
641
768
  private outcomeSaid = false;
642
769
  private taskOrder = 0;
643
- private hookHandles = 0;
770
+ /** The slots of RegisterHookChain's and RegisterHam's handles, which DisableHookChain and the rest take. */
771
+ private readonly hookSlots = new Map<number, Slot>();
644
772
  private entityIds: number;
645
773
 
646
774
  constructor(options: ServerOptions = {}) {
647
775
  this.map = options.map ?? 'de_dust2';
648
776
  this.maxPlayers = options.maxPlayers ?? 32;
649
777
  this.modules = new Set(options.modules ?? ['reapi', 'cstrike', 'fun', 'hamsandwich', 'engine', 'fakemeta', 'nvault', 'resemiclip']);
778
+ this.reunion = options.reunion ?? false;
650
779
  this.entityIds = this.maxPlayers + 1;
651
780
  this.timeZone = options.timeZone;
652
781
  // AMX Mod X's own, which a test may set: the languages.
@@ -870,11 +999,29 @@ export class FakeServer {
870
999
  this.entities.set(id, player);
871
1000
 
872
1001
  this.fire('client_connect', id);
873
- this.fire('client_authorized', id, player.authid);
1002
+ this.fire('client_authorized', id, player.steamId);
874
1003
  this.fire('client_putinserver', id);
875
1004
  return player;
876
1005
  }
877
1006
 
1007
+ /** The module's network client: the requests plugins sent with fetch. @internal */
1008
+ readonly network = new FakeNetwork(this);
1009
+
1010
+ /**
1011
+ * Waits for the responses to the requests the plugins sent with `fetch`,
1012
+ * and hands each to its plugin as the server's next frame would; a
1013
+ * request sent on the way is waited for too. A timer between two tries
1014
+ * waits for `advance()`.
1015
+ *
1016
+ * ```ts
1017
+ * player.say("/weather");
1018
+ * await server.responses();
1019
+ * ```
1020
+ */
1021
+ async responses(): Promise<void> {
1022
+ await this.network.settle();
1023
+ }
1024
+
878
1025
  /**
879
1026
  * Moves the clock forward and runs every timer that comes due on the way,
880
1027
  * in order - a repeating one as many times as it fits.
@@ -913,6 +1060,7 @@ export class FakeServer {
913
1060
 
914
1061
  let result = 0;
915
1062
  for (const handler of [...(this.events.get(name) ?? [])]) {
1063
+ if (handler.where && Number(args[handler.where.arg]) !== handler.where.value) continue;
916
1064
  const one = this.call(handler, [first], 0);
917
1065
  if (one > result) result = one;
918
1066
  }
@@ -932,24 +1080,26 @@ export class FakeServer {
932
1080
  *
933
1081
  * ```ts
934
1082
  * server.fireHook("takeDamage", [victim.id, 0, attacker.id, 30.0, 2]);
935
- * server.fireHook("flPlayerFallDamage", [player.id], { result: 40 });
1083
+ * server.fireHook("fallDamage", [player.id], { result: 40 });
936
1084
  * ```
937
1085
  *
938
1086
  * `event` is the name game.addEventListener takes, or reapi's short one
939
1087
  * ("take_damage"). `args` are the chain's arguments in order; a float is
940
- * written as a number and goes as one. `result` is what the game's own
1088
+ * written as a number and goes as one, a vector as `[x, y, z]`. `result` is what the game's own
941
1089
  * function answers when it runs - what a post listener reads as
942
1090
  * event.result.
943
1091
  */
944
- fireHook(event: string, args: Value[] = [], options: { result?: Value } = {}): HookResult {
1092
+ fireHook(event: string, args: HookArg[] = [], options: { result?: Value } = {}): HookResult {
945
1093
  const shape = tables().hooks.get(event);
946
1094
  if (!shape) throw new Error(`no hookchain named "${event}" in as/hooks.ts`);
947
1095
 
948
- const cells = args.map((a, i) => typeof a === 'string'
949
- ? a
950
- : typeof a === 'boolean'
951
- ? (a ? 1 : 0)
952
- : shape.floats.has(i) ? floatBits(a) : a | 0);
1096
+ const cells: ArgValue[] = args.map((a, i) => Array.isArray(a)
1097
+ ? a.map(floatBits)
1098
+ : typeof a === 'string'
1099
+ ? a
1100
+ : typeof a === 'boolean'
1101
+ ? (a ? 1 : 0)
1102
+ : shape.floats.has(i) ? floatBits(a) : a | 0);
953
1103
  const handed = cells.slice(0, 4).map(c => typeof c === 'number' ? c : 0);
954
1104
 
955
1105
  const nothing: Value = shape.answer === constant('ATYPE_BOOL') ? false : shape.answer === constant('ATYPE_STRING') ? '' : 0;
@@ -980,6 +1130,12 @@ export class FakeServer {
980
1130
  if (!prevented) chain.answer = options.result ?? nothing;
981
1131
 
982
1132
  if (!broken) {
1133
+ // A vector is an array reapi copies back into the game's: what
1134
+ // a pre listener wrote in it is what the game and the post
1135
+ // listeners get.
1136
+ cells.forEach((cell, i) => {
1137
+ if (Array.isArray(cell)) chain.args[i] = cell.map(bitsFloat);
1138
+ });
983
1139
  for (const slot of [...listeners.post]) {
984
1140
  this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
985
1141
  }
@@ -1001,8 +1157,11 @@ export class FakeServer {
1001
1157
  * const sent = server.sendMessage("RoundTime", [120]); // sent.args: [90] with a listener that writes 90
1002
1158
  * ```
1003
1159
  *
1004
- * A text argument is a string; a whole number is written as a byte, a
1005
- * fraction as a coordinate, unless `types` gives each argument's ARG_*.
1160
+ * `name` is the game's name of the message, `"TextMsg"` for the
1161
+ * `"text"` that server.addMessageListener takes, and `args` are its
1162
+ * arguments in the game's order. A text argument is a string; a whole
1163
+ * number is written as a byte, a fraction as a coordinate, unless `types`
1164
+ * gives each argument's ARG_*.
1006
1165
  * Returns whether a callback stopped it, and the arguments as they left.
1007
1166
  */
1008
1167
  sendMessage(name: string, args: (number | string)[], options: { player?: FakePlayer; types?: number[] } = {}): { prevented: boolean; args: (number | string)[] } {
@@ -1020,6 +1179,14 @@ export class FakeServer {
1020
1179
  const handed = [id ?? 0, receiver ? 1 : 0, receiver, 0];
1021
1180
  if (this.withCallArgs(handed, () => this.call(slot, handed, 0)) === PLUGIN_HANDLED) prevented = true;
1022
1181
  }
1182
+ // register_event's callbacks hear it once it is sent, when its conditions hold.
1183
+ const holds = (condition: string) => {
1184
+ const match = condition.match(/^(\d+)=(.*)$/);
1185
+ return !match || String(message.args[Number(match[1]) - 1]) === match[2];
1186
+ };
1187
+ for (const { slot } of this.messageEvents.filter(one => one.name === name && one.conditions.every(holds))) {
1188
+ if (!prevented) this.withCallArgs([receiver], () => this.call(slot, [receiver, 0, 0, 0], 0));
1189
+ }
1023
1190
  return { prevented, args: message.args };
1024
1191
  } finally {
1025
1192
  this.hookedMessage = previous;
@@ -1041,7 +1208,7 @@ export class FakeServer {
1041
1208
  * function's arguments after the entity, a float as a number and a vector
1042
1209
  * as an array of three.
1043
1210
  */
1044
- fireHam(event: string, entity: FakeEntity | number, args: Value[] = [], options: { result?: Value } = {}): HookResult {
1211
+ fireHam(event: string, entity: FakeEntity | number, args: HookArg[] = [], options: { result?: Value } = {}): HookResult {
1045
1212
  const shape = tables().hooks.get(event);
1046
1213
  if (shape?.ham === undefined) throw new Error(`no Ham Sandwich function under the event "${event}" in as/hooks.ts`);
1047
1214
  return this.runHam(shape, typeof entity === 'number' ? entity : entity.id, args, options.result);
@@ -1051,7 +1218,7 @@ export class FakeServer {
1051
1218
  * fireHam's work, and ExecuteHamB's: `args` without the entity.
1052
1219
  * @internal
1053
1220
  */
1054
- runHam(shape: HookShape, id: number, args: (Value | number[])[], result?: Value): HookResult {
1221
+ runHam(shape: HookShape, id: number, args: HookArg[], result?: Value): HookResult {
1055
1222
  const classname = this.entities.get(id)?.classname ?? (id >= 1 && id <= this.maxPlayers ? 'player' : '');
1056
1223
  const all = [id, ...args];
1057
1224
  const cells: ArgValue[] = all.map((a, i) => Array.isArray(a)
@@ -1063,7 +1230,7 @@ export class FakeServer {
1063
1230
  : shape.floats.has(i) ? floatBits(a) : a | 0);
1064
1231
  const handed = cells.slice(0, 4).map(c => typeof c === 'number' ? c : 0);
1065
1232
  const nothing: Value = shape.answer === constant('ATYPE_STRING') ? '' : 0;
1066
- const chain: Chain = { shape, answer: nothing, args: all.map(a => Array.isArray(a) ? 0 : a) };
1233
+ const chain: Chain = { shape, answer: nothing, args: all };
1067
1234
  const listeners = (post: boolean) => this.hams.get(`${shape.ham}:${classname}:${post ? 'post' : 'pre'}`) ?? [];
1068
1235
 
1069
1236
  const previous = this.chain;
@@ -1281,7 +1448,7 @@ export class FakeServer {
1281
1448
 
1282
1449
  /**
1283
1450
  * A plugin calling another plugin's `export function` native
1284
- * through its ~/natives wrapper: the cells it pushed - text and buffers as
1451
+ * through its @amxts/core/natives wrapper: the cells it pushed - text and buffers as
1285
1452
  * addresses in its memory - turned into the frame the native reads, and
1286
1453
  * what the native wrote into a buffer copied back, as AMX Mod X does.
1287
1454
  * @internal
@@ -1344,6 +1511,9 @@ export class FakeServer {
1344
1511
 
1345
1512
  /** Fire in module.cpp: one handler, its outcome, and the state put back. @internal */
1346
1513
  call(handler: Handler, args: number[], fallback: number): number {
1514
+ // An orphan answers PLUGIN_CONTINUE, as in n_callback: a command passes on to the plugin loaded since.
1515
+ if (handler.plugin.unloaded) return 0;
1516
+ if ((handler as Slot).off) return fallback;
1347
1517
  const fn = handler.plugin.table.get(handler.fn);
1348
1518
  const cells = handler.shape === SHAPE_WIDE ? [0, 1, 2, 3].map(i => args[i] ?? 0) : [args[0] ?? 0];
1349
1519
  if (handler.tag) cells.unshift(handler.tag);
@@ -1431,6 +1601,208 @@ export class FakeServer {
1431
1601
  }
1432
1602
  }
1433
1603
 
1604
+ /**
1605
+ * Takes a plugin off the server, as `amxts_reload` does before it loads
1606
+ * the plugins again: its timers are removed, and a call to one of its
1607
+ * other slots answers nothing, until a plugin of the same file asks for
1608
+ * the same key and takes the slot back. `load` the file again for the
1609
+ * reload's second half.
1610
+ */
1611
+ unload(plugin: PluginInstance): void {
1612
+ plugin.unloaded = true;
1613
+ this.plugins.splice(this.plugins.indexOf(plugin), 1);
1614
+ for (let i = this.tasks.length - 1; i >= 0; i--) {
1615
+ if (this.tasks[i].slot.plugin === plugin) this.tasks.splice(i, 1);
1616
+ }
1617
+
1618
+ // The modules it served answer nothing; the owners of those it called drop what it gave them (TellOwners).
1619
+ for (const service of this.services.filter(each => each.plugin === plugin)) service.plugin = null;
1620
+ const owners = this.services.filter(each => plugin.uses.includes(each.name) && each.plugin && each.plugin !== plugin);
1621
+ const gone = new Uint8Array(new Int32Array([RPC_GONE, 0]).buffer);
1622
+ for (const owner of owners) this.request(owner.plugin!, plugin.run, gone);
1623
+ }
1624
+
1625
+ /** A menu by its id, or the error AMX Mod X gives for another number. @internal */
1626
+ menu(id: number): FakeMenu {
1627
+ const menu = this.menus.get(id);
1628
+ if (!menu) throw new Error(`Invalid menu id ${id}`);
1629
+ return menu;
1630
+ }
1631
+
1632
+ /** menu_create: a menu of seven items a page, with Back, More and Exit. @internal */
1633
+ createMenu(title: string, handler: Slot): number {
1634
+ const id = this.menuIds++;
1635
+ this.menus.set(id, { id, title, handler, items: [], perPage: 7, exit: true, back: 'Back', next: 'More', exitName: 'Exit', numberColor: '\\r' });
1636
+ return id;
1637
+ }
1638
+
1639
+ /** menu_destroy: whoever has it open sees it close, and its handler hears MENU_EXIT. @internal */
1640
+ destroyMenu(id: number): void {
1641
+ const menu = this.menu(id);
1642
+ this.menus.delete(id);
1643
+ for (const player of this.players.filter(one => one.menu?.menu === id)) {
1644
+ player.menu = null;
1645
+ this.menuAnswer(menu, player, constant('MENU_EXIT'));
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * menu_display: a page drawn for a player. An item with a callback is
1651
+ * asked as AMX Mod X asks it, as it is drawn - the callback may name it
1652
+ * for him, and its answer greys it out. Another menu he had open is
1653
+ * closed first: its handler hears MENU_EXIT. @internal
1654
+ */
1655
+ displayMenu(player: FakePlayer, menu: FakeMenu, page: number): void {
1656
+ const before = player.menu;
1657
+ player.menu = null;
1658
+ const replaced = before && before.menu !== menu.id ? this.menus.get(before.menu) : undefined;
1659
+ if (replaced) this.menuAnswer(replaced, player, constant('MENU_EXIT'));
1660
+
1661
+ const perPage = menu.perPage === 0 ? Math.min(menu.items.length, 10) : menu.perPage;
1662
+ const pages = Math.max(1, Math.ceil(menu.items.length / Math.max(perPage, 1)));
1663
+ const at = Math.min(Math.max(page, 0), pages - 1);
1664
+ const lines = [`${menu.title}${pages > 1 ? ` ${at + 1}/${pages}` : ''}`, ''];
1665
+ const keys: number[] = [];
1666
+ const disabled: number[] = [];
1667
+ const line = (key: number, text: string) => lines.push(`${menu.numberColor}${key}.\\w ${text}`);
1668
+
1669
+ for (let i = 0; i < perPage && at * perPage + i < menu.items.length; i++) {
1670
+ const item = at * perPage + i;
1671
+ const callback = this.menuCallbacks[menu.items[item].callback];
1672
+ const args = [player.id, menu.id, item];
1673
+ const answer = callback ? this.withCallArgs(args, () => this.call(callback, args, constant('ITEM_IGNORE'))) : constant('ITEM_IGNORE');
1674
+ const key = (i + 1) % 10;
1675
+ keys.push(key);
1676
+ if (answer !== constant('ITEM_DISABLED')) {
1677
+ line(key, menu.items[item].name);
1678
+ continue;
1679
+ }
1680
+ disabled.push(key);
1681
+ lines.push(`\\d${key}. ${menu.items[item].name}`);
1682
+ }
1683
+
1684
+ const paged = menu.perPage > 0;
1685
+ if (paged) lines.push('');
1686
+ if (paged && at > 0) {
1687
+ keys.push(8);
1688
+ line(8, menu.back);
1689
+ }
1690
+ if (paged && at < pages - 1) {
1691
+ keys.push(9);
1692
+ line(9, menu.next);
1693
+ }
1694
+ if (paged && menu.exit) {
1695
+ keys.push(0);
1696
+ line(0, menu.exitName);
1697
+ }
1698
+
1699
+ player.menu = { menu: menu.id, page: at, text: lines.join('\n'), keys, disabled };
1700
+ }
1701
+
1702
+ /** A key on a menu of AMX Mod X's own: an item to the handler, Back and More a page, Exit MENU_EXIT. Whether the menu took it. @internal */
1703
+ selectMenu(player: FakePlayer, key: number): boolean {
1704
+ const screen = player.menu;
1705
+ const menu = screen ? this.menus.get(screen.menu) : undefined;
1706
+ if (!screen || !menu || !screen.keys.includes(key)) return false;
1707
+
1708
+ const paged = menu.perPage > 0;
1709
+ const turn = paged && key === 8 ? -1 : paged && key === 9 ? 1 : 0;
1710
+ if (turn !== 0 || screen.disabled.includes(key)) {
1711
+ this.displayMenu(player, menu, screen.page + turn);
1712
+ return true;
1713
+ }
1714
+
1715
+ player.menu = null;
1716
+ const perPage = paged ? menu.perPage : 10;
1717
+ this.menuAnswer(menu, player, paged && key === 0 ? constant('MENU_EXIT') : screen.page * perPage + (key === 0 ? 10 : key) - 1);
1718
+ return true;
1719
+ }
1720
+
1721
+ private menuAnswer(menu: FakeMenu, player: FakePlayer, item: number): void {
1722
+ const args = [player.id, menu.id, item];
1723
+ this.withCallArgs(args, () => this.call(menu.handler, args, 0));
1724
+ }
1725
+
1726
+ /**
1727
+ * A line of the game's log, as the engine hands it to AMX Mod X: split
1728
+ * into its arguments as AMX Mod X splits it, and every logevent callback
1729
+ * whose filters it passes is called.
1730
+ *
1731
+ * ```ts
1732
+ * server.gameLog('World triggered "Round_End"');
1733
+ * server.gameLog(`"${alice.name}<${alice.userid}><STEAM_0:0:1><TERRORIST>" triggered "Planted_The_Bomb"`);
1734
+ * ```
1735
+ */
1736
+ gameLog(line: string): void {
1737
+ // AMX Mod X keeps one parsed line: one logged while a callback runs
1738
+ // replaces it, and the walk of the outer line's callbacks goes on with it.
1739
+ this.logLine = line;
1740
+ this.logArgs = splitLog(line);
1741
+ const argc = this.logArgs.length;
1742
+ const passes = (filter: string) => {
1743
+ const match = filter.match(/^(\d+)([=&!])(.*)$/);
1744
+ if (!match) return true;
1745
+ const value = this.logArgs[Number(match[1])] ?? '';
1746
+ return match[2] === '=' ? value === match[3] : match[2] === '&' ? value.includes(match[3]) : value !== match[3];
1747
+ };
1748
+ for (const event of [...this.logEvents]) {
1749
+ if (event.argc === argc && event.filters.every(passes)) this.withCallArgs([], () => this.call(event.slot, [0, 0, 0, 0], 0));
1750
+ }
1751
+ }
1752
+
1753
+ /**
1754
+ * A fakemeta function the game calls, through the register_forward
1755
+ * callbacks of it: the pre ones, then, unless one of them superseded it,
1756
+ * the post ones with `result` as get_orig_retval reads it. Returns whether
1757
+ * it was superseded and what a callback answered with forward_return.
1758
+ *
1759
+ * ```ts
1760
+ * server.fireForward('FM_EmitSound', [alice.id, 2, 'player/die1.wav', 1.0, 0.8, 0, 100]);
1761
+ * ```
1762
+ */
1763
+ fireForward(name: string, args: ArgValue[], result = 0): { superseded: boolean; answer: number | string | null } {
1764
+ const fn = constant(name);
1765
+ const handed = args.slice(0, 4).map(a => typeof a === 'number' ? a : 0);
1766
+ const cells = args.map(a => typeof a === 'number' && !Number.isInteger(a) ? floatBits(a) : a);
1767
+ this.forwardAnswer = null;
1768
+ let superseded = false;
1769
+ for (const slot of [...(this.fakemetaForwards.get(`${fn}:pre`) ?? [])]) {
1770
+ if (this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback)) >= FMRES_SUPERCEDE) superseded = true;
1771
+ }
1772
+ if (!superseded) {
1773
+ this.origRetval = result;
1774
+ for (const slot of [...(this.fakemetaForwards.get(`${fn}:post`) ?? [])]) this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
1775
+ }
1776
+ return { superseded, answer: this.forwardAnswer };
1777
+ }
1778
+
1779
+ /** Switches a hook off or on by the handle RegisterHookChain or RegisterHam gave. @internal */
1780
+ switchHook(handle: number, on: boolean): number {
1781
+ const slot = this.hookSlots.get(handle);
1782
+ if (!slot) throw new Error(`the fake server knows no hook handle ${handle}`);
1783
+ slot.off = !on;
1784
+ return 1;
1785
+ }
1786
+
1787
+ /**
1788
+ * Whether a game event's hook calls the plugin - reapi's chain, or Ham
1789
+ * Sandwich's function on a class: false while the plugin has switched it
1790
+ * off, for having no listener; undefined when it was never registered.
1791
+ *
1792
+ * ```ts
1793
+ * server.hooked('resetMaxSpeed'); // the chain, before the game
1794
+ * server.hooked('spawn', { classname: 'player', post: true });
1795
+ * ```
1796
+ */
1797
+ hooked(event: string, options: { post?: boolean; classname?: string } = {}): boolean | undefined {
1798
+ const shape = tables().hooks.get(event);
1799
+ if (!shape) throw new Error(`no hookchain named "${event}" in as/hooks.ts`);
1800
+ const slots = options.classname !== undefined
1801
+ ? this.hams.get(`${shape.ham}:${options.classname}:${options.post ? 'post' : 'pre'}`)
1802
+ : this.hookchains.get(shape.kind)?.[options.post ? 'post' : 'pre'];
1803
+ return slots?.length ? slots.some(slot => !slot.off) : undefined;
1804
+ }
1805
+
1434
1806
  /** A slot by the public name a native was given: "__amxts_cb3". @internal */
1435
1807
  slotByPublic(name: string): Slot {
1436
1808
  const match = name.match(/^__amxts_cb(\d+)$/);
@@ -1507,11 +1879,21 @@ export class FakeServer {
1507
1879
  }
1508
1880
 
1509
1881
  /** Modules a plugin runs for the others (module.cpp's g_services), and the bytes of the call in flight. @internal */
1510
- private services: { name: string; hash: number; plugin: number }[] = [];
1882
+ private services: { name: string; hash: number; plugin: PluginInstance | null }[] = [];
1511
1883
  private rpcRequest = new Uint8Array(0);
1512
1884
  private rpcReply = new Uint8Array(0);
1513
1885
  private rpcResult = new Uint8Array(0);
1514
1886
 
1887
+ /** Runs a request in `callee`'s __amxts_rpc, from the run `from` (module.cpp's RunRequest): its answer, or null. */
1888
+ private request(callee: PluginInstance, from: number, request: Uint8Array<ArrayBuffer>): Uint8Array | null {
1889
+ const rpc = callee.instance.exports.__amxts_rpc;
1890
+ if (!rpc) return null;
1891
+ this.rpcRequest = request;
1892
+ this.rpcReply = new Uint8Array(0);
1893
+ this.within(callee, () => rpc(this.rpcRequest.length, from));
1894
+ return this.rpcReply;
1895
+ }
1896
+
1515
1897
  /**
1516
1898
  * The externals the facade declares, as module.cpp registers
1517
1899
  * them in g_wasmNatives. Each is called with the plugin that called it.
@@ -1521,30 +1903,32 @@ export class FakeServer {
1521
1903
  // Shared modules: module.cpp's w_serve, w_owner, w_rpc and the three that move the bytes.
1522
1904
  amxts_serve(this: FakeServer, plugin: PluginInstance, name: number, hash: number) {
1523
1905
  const wanted = plugin.memory.string(name);
1524
- const index = this.plugins.indexOf(plugin);
1525
1906
  const known = this.services.find(s => s.name === wanted);
1526
- if (known) Object.assign(known, { hash, plugin: index });
1527
- else this.services.push({ name: wanted, hash, plugin: index });
1907
+ if (known) Object.assign(known, { hash, plugin });
1908
+ else this.services.push({ name: wanted, hash, plugin });
1528
1909
  },
1529
1910
 
1530
1911
  amxts_owner(this: FakeServer, plugin: PluginInstance, name: number, hash: number) {
1531
1912
  const wanted = plugin.memory.string(name);
1913
+ if (!plugin.uses.includes(wanted)) plugin.uses.push(wanted);
1532
1914
  const index = this.services.findIndex(s => s.name === wanted);
1533
- if (index < 0 || this.services[index].plugin < 0) return -1;
1915
+ if (index < 0 || !this.services[index].plugin) return -1;
1534
1916
  return this.services[index].hash === hash ? index : -2;
1535
1917
  },
1536
1918
 
1537
1919
  amxts_rpc(this: FakeServer, plugin: PluginInstance, service: number, target: number, data: number, length: number) {
1538
- const index = service >= 0 ? (this.services[service]?.plugin ?? -1) : target;
1539
- const callee = this.plugins[index];
1540
- const rpc = callee?.instance.exports.__amxts_rpc;
1541
- if (!rpc) return -1;
1542
- this.rpcRequest = new Uint8Array(plugin.instance.exports.memory.buffer, data, length).slice();
1543
- this.rpcReply = new Uint8Array(0);
1544
- this.within(callee, () => rpc(length, this.plugins.indexOf(plugin)));
1545
- const answer = new Uint8Array(4 + this.rpcReply.length);
1546
- new DataView(answer.buffer).setInt32(0, index, true);
1547
- answer.set(this.rpcReply, 4);
1920
+ const callee = service >= 0 ? this.services[service]?.plugin : this.plugins.find(one => one.run === target);
1921
+
1922
+ if (service < 0 && !callee && !plugin.toldGone) {
1923
+ plugin.toldGone = true;
1924
+ this.logLines.push(`[amxts] ${plugin.source} called back a function of a plugin that was unloaded or reloaded since - the call answers nothing`);
1925
+ }
1926
+
1927
+ const reply = callee ? this.request(callee, plugin.run, new Uint8Array(plugin.instance.exports.memory.buffer, data, length).slice()) : null;
1928
+ if (!callee || !reply) return -1;
1929
+ const answer = new Uint8Array(4 + reply.length);
1930
+ new DataView(answer.buffer).setInt32(0, callee.run, true);
1931
+ answer.set(reply, 4);
1548
1932
  this.rpcResult = answer;
1549
1933
  return answer.length;
1550
1934
  },
@@ -1563,11 +1947,58 @@ export class FakeServer {
1563
1947
  this.rpcResult = new Uint8Array(0);
1564
1948
  },
1565
1949
 
1950
+ // The network client: module.cpp's w_net_*, over Bun's fetch and FTP and SFTP clients (network.ts).
1951
+ net_open(this: FakeServer, plugin: PluginInstance, url: number) {
1952
+ return this.network.open(plugin, plugin.memory.string(url));
1953
+ },
1954
+ net_option(this: FakeServer, plugin: PluginInstance, id: number, name: number, value: number) {
1955
+ return this.network.option(plugin, id, plugin.memory.string(name), plugin.memory.string(value));
1956
+ },
1957
+ net_body(this: FakeServer, plugin: PluginInstance, id: number, data: number, length: number) {
1958
+ this.network.body(plugin, id, new Uint8Array(plugin.instance.exports.memory.buffer, data, length).slice());
1959
+ },
1960
+ net_send(this: FakeServer, plugin: PluginInstance, id: number, fn: number) {
1961
+ return this.network.send(plugin, id, fn);
1962
+ },
1963
+ net_cancel(this: FakeServer, plugin: PluginInstance, id: number) {
1964
+ this.network.cancel(plugin, id);
1965
+ },
1966
+ net_close(this: FakeServer, plugin: PluginInstance, id: number) {
1967
+ this.network.cancel(plugin, id);
1968
+ },
1969
+ net_status(this: FakeServer, plugin: PluginInstance, id: number) {
1970
+ return this.network.status(plugin, id);
1971
+ },
1972
+ net_redirects(this: FakeServer, plugin: PluginInstance, id: number) {
1973
+ return this.network.redirects(plugin, id);
1974
+ },
1975
+ net_text(this: FakeServer, plugin: PluginInstance, id: number, what: number, out: number, max: number) {
1976
+ return this.network.text(plugin, id, what, out, max);
1977
+ },
1978
+ net_size(this: FakeServer, plugin: PluginInstance, id: number) {
1979
+ return this.network.size(plugin, id);
1980
+ },
1981
+ net_read(this: FakeServer, plugin: PluginInstance, id: number, out: number, max: number) {
1982
+ return this.network.read(plugin, id, out, max);
1983
+ },
1984
+ net_reply(this: FakeServer, plugin: PluginInstance, id: number) {
1985
+ return this.network.reply(plugin, id);
1986
+ },
1987
+
1566
1988
  abort(this: FakeServer, plugin: PluginInstance, message: number, file: number, line: number, column: number) {
1567
1989
  const text = message ? plugin.memory.string(message) : 'assertion failed';
1568
1990
  throw new Error(`abort: ${text} (${file ? plugin.memory.string(file) : '?'}:${line}:${column})`);
1569
1991
  },
1570
1992
 
1993
+ // An error's stack: the server reads frames WAMR keeps, which a
1994
+ // plugin run here has none of - `stack` is the error's first line.
1995
+ stack_frames() {
1996
+ return 0;
1997
+ },
1998
+ stack_text() {
1999
+ return 0;
2000
+ },
2001
+
1571
2002
  seed() {
1572
2003
  return Date.now();
1573
2004
  },
@@ -1636,6 +2067,22 @@ export class FakeServer {
1636
2067
  list.push({ plugin, fn, shape });
1637
2068
  },
1638
2069
 
2070
+ // The plugin's handler of a forward taken off, once its event has no listener left.
2071
+ off(this: FakeServer, plugin: PluginInstance, name: number, fn: number) {
2072
+ const list = this.events.get(plugin.memory.string(name)) ?? [];
2073
+ for (let i = list.length - 1; i >= 0; i--) {
2074
+ if (list[i].plugin === plugin && list[i].fn === fn) list.splice(i, 1);
2075
+ }
2076
+ },
2077
+
2078
+ // on, for the calls whose argument `arg` is `value` alone - compared before the plugin, as module.cpp does.
2079
+ on_cell(this: FakeServer, plugin: PluginInstance, name: number, fn: number, shape: number, arg: number, value: number) {
2080
+ const event = plugin.memory.string(name);
2081
+ let list = this.events.get(event);
2082
+ if (!list) this.events.set(event, list = []);
2083
+ list.push({ plugin, fn, shape, where: { arg, value } });
2084
+ },
2085
+
1639
2086
  subscribe(this: FakeServer, plugin: PluginInstance, name: number, fn: number, tag: number) {
1640
2087
  const forward = plugin.memory.string(name);
1641
2088
  let list = this.subscriptions.get(forward);
@@ -1661,8 +2108,19 @@ export class FakeServer {
1661
2108
  this.withCallArgs(args, () => this.deliver(forward));
1662
2109
  },
1663
2110
 
2111
+ // A slot a reload left takes the same file's plugin back, as SLOT_REUSED says in module.cpp.
1664
2112
  slot(this: FakeServer, plugin: PluginInstance, fn: number, shape: number, key: number, fallback: number) {
1665
- return this.takeSlot(plugin, fn, shape, plugin.memory.string(key), fallback).index;
2113
+ const name = plugin.memory.string(key);
2114
+ const left = name ? this.slots.find(one => one.key === name && one.plugin.unloaded && one.plugin.source === plugin.source) : undefined;
2115
+ if (!left) return this.takeSlot(plugin, fn, shape, name, fallback).index;
2116
+ Object.assign(left, { plugin, fn, shape, fallback, tag: this.takeTag() });
2117
+ return left.index | SLOT_REUSED;
2118
+ },
2119
+
2120
+ // One of the plugin's slots switched off while what it delivers has no listener, or back on.
2121
+ slot_on(this: FakeServer, plugin: PluginInstance, index: number, on: number) {
2122
+ const slot = this.slots[index];
2123
+ if (slot?.plugin === plugin) slot.off = on === 0;
1666
2124
  },
1667
2125
 
1668
2126
  clcmd(this: FakeServer, plugin: PluginInstance, pattern: number, fn: number, flags: number, info: number, shape: number) {
@@ -1698,7 +2156,8 @@ export class FakeServer {
1698
2156
  let chain = this.hookchains.get(kind);
1699
2157
  if (!chain) this.hookchains.set(kind, chain = { pre: [], post: [] });
1700
2158
  (post ? chain.post : chain.pre).push(slot);
1701
- return ++this.hookHandles;
2159
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2160
+ return this.hookSlots.size;
1702
2161
  },
1703
2162
 
1704
2163
  // RegisterHam: kept by the function's id and the class, for hamCall().
@@ -1708,7 +2167,8 @@ export class FakeServer {
1708
2167
  const list = this.hams.get(key) ?? [];
1709
2168
  list.push(slot);
1710
2169
  this.hams.set(key, list);
1711
- return ++this.hookHandles;
2170
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2171
+ return this.hookSlots.size;
1712
2172
  },
1713
2173
 
1714
2174
  tag(this: FakeServer, _plugin: PluginInstance, tag: number) {
@@ -1788,6 +2248,64 @@ export class FakeServer {
1788
2248
  return length;
1789
2249
  },
1790
2250
 
2251
+ // Entity fields and members, as the module reads them where the game
2252
+ // keeps them (runtime/src/fields.h): an entvar by its offset in
2253
+ // entvars_t, a member by the slot member_slot gave its gamedata name,
2254
+ // the game rules' by the id -1.
2255
+ ent_get(this: FakeServer, plugin: PluginInstance, id: number, offset: number) {
2256
+ const at = tables().entvarAt.get(offset);
2257
+ return at ? fieldCell(this.entities.get(id), at.field, at.component) : 0;
2258
+ },
2259
+
2260
+ ent_set(this: FakeServer, plugin: PluginInstance, id: number, offset: number, cell: number) {
2261
+ const at = tables().entvarAt.get(offset);
2262
+ if (at) setFieldCell(this.entities.get(id), at.field, cell, at.component);
2263
+ },
2264
+
2265
+ ent_entity(this: FakeServer, plugin: PluginInstance, id: number, offset: number) {
2266
+ const at = tables().entvarAt.get(offset);
2267
+ return at ? fieldCell(this.entities.get(id), at.field) : 0;
2268
+ },
2269
+
2270
+ ent_set_entity(this: FakeServer, plugin: PluginInstance, id: number, offset: number, index: number) {
2271
+ const at = tables().entvarAt.get(offset);
2272
+ if (at) setFieldCell(this.entities.get(id), at.field, Math.max(index, 0));
2273
+ },
2274
+
2275
+ member_slot(this: FakeServer, plugin: PluginInstance, className: number, name: number) {
2276
+ const key = `${plugin.memory.string(className)}::${plugin.memory.string(name)}`;
2277
+ const field = tables().memberNamed.get(key);
2278
+ if (field === undefined) throw new Error(`no reapi member is ${key} in the gamedata`);
2279
+ const slot = this.memberSlots.indexOf(field);
2280
+ return slot >= 0 ? slot : this.memberSlots.push(field) - 1;
2281
+ },
2282
+
2283
+ member_get(this: FakeServer, plugin: PluginInstance, id: number, slot: number, element: number) {
2284
+ const field = this.memberSlots[slot];
2285
+ if (id === -1) return this.rules.get(field) ?? 0;
2286
+ return fieldCell(this.entities.get(id), field, element);
2287
+ },
2288
+
2289
+ member_set(this: FakeServer, plugin: PluginInstance, id: number, slot: number, element: number, cell: number) {
2290
+ const field = this.memberSlots[slot];
2291
+ if (id === -1) this.rules.set(field, cell);
2292
+ else setFieldCell(this.entities.get(id), field, cell, element);
2293
+ },
2294
+
2295
+ member_text(this: FakeServer, plugin: PluginInstance, id: number, slot: number, out: number, max: number) {
2296
+ const value = this.entities.get(id)?.fields.get(FakeEntity.key(this.memberSlots[slot]));
2297
+ return plugin.memory.setUtf8(out, max, typeof value === 'string' ? value : '');
2298
+ },
2299
+
2300
+ member_set_text(this: FakeServer, plugin: PluginInstance, id: number, slot: number, text: number) {
2301
+ this.entities.get(id)?.fields.set(FakeEntity.key(this.memberSlots[slot]), plugin.memory.string(text));
2302
+ },
2303
+
2304
+ // The fake keeps its game rules where the module would find them.
2305
+ game_rules() {
2306
+ return 1;
2307
+ },
2308
+
1791
2309
  // The fields plugins add to Player: the imports their generated accessors call.
1792
2310
  player_data_get(this: FakeServer, plugin: PluginInstance, id: number, key: number) {
1793
2311
  return this.playerNumber(id, plugin.memory.string(key));
@@ -1811,7 +2329,7 @@ export class FakeServer {
1811
2329
  this.setPlayerData(id, plugin.memory.string(key), plugin.memory.string(value), true);
1812
2330
  },
1813
2331
 
1814
- // playerchange: the module wakes a plugin for the fields it listens for.
2332
+ // playerChange: the module wakes a plugin for the fields it listens for.
1815
2333
  player_change_listen(this: FakeServer, plugin: PluginInstance, field: number, fn: number) {
1816
2334
  const name = plugin.memory.string(field);
1817
2335
  const known = this.fieldListeners.find(one => one.plugin === plugin);