@amxts/core 0.2.3 → 0.3.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 (71) hide show
  1. package/README.md +5 -5
  2. package/README.ru.md +5 -5
  3. package/as/amxts.d.ts +13 -3
  4. package/as/entities.ts +571 -501
  5. package/as/events.ts +3323 -557
  6. package/as/facade.ts +3079 -718
  7. package/as/fetch.ts +2 -2
  8. package/as/fs.ts +352 -59
  9. package/as/hlds.ts +121 -120
  10. package/as/hooks.ts +10585 -5177
  11. package/as/lib/check.ts +3 -2
  12. package/as/natives.ts +856 -1272
  13. package/as/promise.ts +37 -27
  14. package/as/vector.ts +94 -14
  15. package/includes/order.txt +17 -19
  16. package/node_modules/assemblyscript/std/assembly/index.d.ts +1 -1
  17. package/package.json +9 -6
  18. package/runtime/deps/assemblyscript/dist/asc.js +136 -42
  19. package/runtime/deps/assemblyscript/dist/assemblyscript.js +141 -141
  20. package/runtime/licenses/README.md +9 -7
  21. package/runtime/licenses/ReGameDLL-LICENSE +21 -0
  22. package/runtime/licenses/ReHLDS-LICENSE +21 -0
  23. package/runtime/licenses/zlib-LICENSE +22 -0
  24. package/runtime/natives.txt +618 -523
  25. package/runtime/patches/README.md +87 -1
  26. package/runtime/patches/assemblyscript-0.28.20-amxts.patch +4838 -416
  27. package/runtime/patches/wamr-2.4.5-amxts.patch +417 -5
  28. package/scripts/auto-imports.ts +1 -1
  29. package/scripts/build-identity.ts +102 -65
  30. package/scripts/build-wasm.ts +17 -13
  31. package/scripts/check.ts +4 -1
  32. package/scripts/client-messages.ts +73 -1
  33. package/scripts/compile-cache.ts +11 -8
  34. package/scripts/compile-pool.ts +6 -3
  35. package/scripts/compile-worker.ts +23 -23
  36. package/scripts/compile.ts +142 -19
  37. package/scripts/docs/as/amxts.ts +10 -4
  38. package/scripts/docs/as/facade.ts +1779 -275
  39. package/scripts/docs/as/fetch.ts +2 -2
  40. package/scripts/docs/as/fs.ts +190 -6
  41. package/scripts/docs/as/vector.ts +81 -0
  42. package/scripts/docs/entities.ts +12 -0
  43. package/scripts/docs/events.ts +49 -13
  44. package/scripts/docs/game.ts +6 -0
  45. package/scripts/docs/messages.ts +10 -1
  46. package/scripts/hlds-events.ts +11 -11
  47. package/scripts/includes.ts +3 -4
  48. package/scripts/leaf-natives.ts +84 -0
  49. package/scripts/player-fields.ts +1 -1
  50. package/scripts/plugin-natives.ts +1 -1
  51. package/scripts/prebuilt.ts +42 -11
  52. package/scripts/project.ts +63 -4
  53. package/scripts/reapi-events.ts +9 -9
  54. package/scripts/server-env.ts +145 -0
  55. package/scripts/shared-modules.ts +23 -7
  56. package/scripts/system.ts +1 -1
  57. package/scripts/tracked-fs.ts +11 -2
  58. package/scripts/typed-commands.ts +153 -15
  59. package/scripts/upgrade-calls.ts +225 -0
  60. package/scripts/upgrade-names.ts +1 -4
  61. package/scripts/upgrade.ts +47 -34
  62. package/src/cli-api.mjs +3 -2
  63. package/src/system.mjs +21 -11
  64. package/src/testing/compile.ts +31 -8
  65. package/src/testing/coroutines.ts +0 -10
  66. package/src/testing/memory.ts +89 -2
  67. package/src/testing/natives.ts +67 -30
  68. package/src/testing/server.ts +1094 -155
  69. package/src/testing/tables.ts +23 -6
  70. package/src/typecheck.mjs +110 -0
  71. package/runtime/licenses/Bun-LICENSE.md +0 -84
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The natives that never run a plugin's code while they run - leaf natives -
3
+ * read from the sources of AMX Mod X 1.10 and its stock modules.
4
+ *
5
+ * A native is on the list when nothing it does, directly or through what it
6
+ * calls, can reach a plugin: it executes no forward and no callback
7
+ * (ExecuteForward, MF_ExecuteForward, callfunc, a comparator, a parser's
8
+ * reader), calls no game function (MDLL_*, the game's entity functions,
9
+ * ReGameDLL) and no engine function that runs the game's code or that a
10
+ * ReHLDS hookchain, Hamsandwich or ReAPI hooks - no cvar written, no command
11
+ * executed, no message sent, no entity made or removed, nothing precached.
12
+ * What it does is read and write its own memory and AMX Mod X's: its
13
+ * arguments, AMX Mod X's tables (players, tries, arrays, vaults, the
14
+ * dictionary, the plugin list), entity fields, cvar values, info buffers, a
15
+ * command's words, files.
16
+ *
17
+ * Its one way out is an error: LogError runs the error filter of the AMX it
18
+ * was called with, and the natives' image sets none.
19
+ *
20
+ * Every one reads and writes its arguments with get_amxaddr, get_amxstring
21
+ * and set_amxstring (no bounds check), and writes none it declares `const`.
22
+ *
23
+ * What a leaf native allows:
24
+ * - its import is marked `@leaf`: a plugin keeps no shadow-stack frame for
25
+ * a call to it, as no collection can run while it does;
26
+ * - a string it takes that is the plugin's static text - a literal, which
27
+ * never changes - crosses as cells made once a map and kept in the natives'
28
+ * image (the module's KeptText), not converted on every call;
29
+ * - the text it fills is copied back without asking where the plugin's
30
+ * memory is again, as the memory cannot grow while it runs.
31
+ *
32
+ * A native left off - one that may call back, such as ExecuteForward,
33
+ * callfunc_*, set_cvar_*, server_cmd or a Pawn plugin's native - goes the
34
+ * copy way. A wrong entry would let the collector free what a plugin still
35
+ * holds, so a native is added only after its source is read.
36
+ */
37
+ /** The names in `text`, apart by white space. */
38
+ function names(text: string): string[] {
39
+ return text.split(/\s+/).filter(Boolean);
40
+ }
41
+
42
+ export const LEAF_NATIVES: ReadonlySet<string> = new Set([
43
+ // Text and numbers: string.cpp and amxmodx.cpp work on their arguments alone.
44
+ ...names(`add argparse contain containi copy copyc equal equali float_to_str format_time get_char_bytes
45
+ get_time hash_file hash_string is_string_category mb_strtolower mb_strtotitle mb_strtoupper
46
+ mb_ucfirst md5 num_to_str num_to_word parse_loguser parse_time replace replace_string
47
+ replace_stringex setc split_string str_to_float str_to_num strcmp strfind strlen strncmp strtof
48
+ strtok strtok2 strtol`),
49
+ // Files, vaults and data packs: the C runtime and the engine's file system.
50
+ ...names(`delete_file dir_exists fgets file_exists file_size fopen fputs GetFileTime LoadFileForMe mkdir
51
+ next_file open_dir read_dir read_file rename_file rmdir SetFilePermissions unlink write_file
52
+ get_vaultdata remove_vaultdata set_vaultdata vaultdata_exists nvault_lookup nvault_open
53
+ nvault_pset nvault_remove nvault_set nvault_touch ReadPackString WritePackString`),
54
+ // Cvars read, never written: writing one runs Cvar_DirectSet and its hooks.
55
+ ...names(`cvar_exists get_cvar_flags get_cvar_float get_cvar_num get_cvar_pointer get_cvar_string
56
+ get_pcvar_string`),
57
+ // The server's and the players' state read: AMX Mod X's player table, info
58
+ // buffers, a command's words, the message and weapon tables. Not
59
+ // get_user_authid, nor get_players, which asks for it: the engine finds
60
+ // the id through ReHLDS's SV_GetIDString hookchain.
61
+ ...names(`get_amxx_verstring get_flags get_localinfo get_mapname get_modname get_user_index get_user_info
62
+ get_user_ip get_user_msgid get_user_msgname get_user_name get_weaponid get_weaponname
63
+ get_xvar_id is_map_valid read_args read_argv read_flags read_logargv read_logdata xvar_exists`),
64
+ // Plugins, modules, libraries and the dictionary: AMX Mod X's own lists.
65
+ ...names(`AddTranslation CreateLangKey find_plugin_byfile GetLangTransKey is_module_loaded
66
+ is_plugin_loaded lang_exists LibraryExists LookupLangKey module_exists register_dictionary`),
67
+ // Tries and arrays: AMX Mod X's memory. Not ArraySort or SortCustom*, which call a plugin back.
68
+ ...names(`ArrayFindString ArrayGetString ArrayInsertStringAfter ArrayInsertStringBefore ArrayPushString
69
+ ArraySetString TrieDeleteKey TrieGetArray TrieGetCell TrieGetString TrieKeyExists TrieSetArray
70
+ TrieSetCell TrieSetString TrieSnapshotGetKey`),
71
+ // Entities and game data read: fields, private data, the engine's search
72
+ // of entities by a field. Not lookup_sequence, which may load a model
73
+ // through a hookchain, nor cs_find_ent_*, which call the game.
74
+ ...names(`copy_infokey_buffer cs_get_item_id cs_get_translated_item_alias cs_get_user_model eng_get_string
75
+ entity_get_string find_ent_by_class find_ent_by_model find_ent_by_owner find_ent_by_target
76
+ find_ent_by_tname find_ent_data_info find_gamerules_info find_sphere_class GameConfGetAddress
77
+ GameConfGetClassOffset GameConfGetKeyValue GameConfGetOffset get_ent_data_string
78
+ get_gamerules_string get_global_string get_info_keybuffer get_keyvalue get_pdata_string
79
+ has_map_ent_class`),
80
+ // GeoIP: lookups in the databases in memory.
81
+ ...names(`geoip_city geoip_code2 geoip_code2_ex geoip_code3 geoip_code3_ex geoip_continent_code
82
+ geoip_continent_name geoip_country geoip_country_ex geoip_latitude geoip_longitude
83
+ geoip_region_code geoip_region_name geoip_timezone`),
84
+ ]);
@@ -721,7 +721,7 @@ export function playerFieldsSource(fields: PlayerField[]): string {
721
721
  '\t\tconst ids = text.split(",");',
722
722
  '\t\tfor (let i = 0; i < ids.length; i++) {',
723
723
  '\t\t\tconst id = I32.parseInt(unchecked(ids[i]));',
724
- '\t\t\tif (id > 0) list.push(new Player(id));',
724
+ '\t\t\tif (id > 0) list.push(__playerOf(id));',
725
725
  '\t\t}',
726
726
  '\t\treturn list;',
727
727
  '\t}',
@@ -988,7 +988,7 @@ function crossForwards(sources: any[], contract: Contract | null, problems: stri
988
988
  } else if (type === 'Vector' && param.type !== 'Float') {
989
989
  problems.push(`${where} - argument ${i + 1} is ${pawn} in the include: a Vector is three Floats, Float:${param.name}[3]`);
990
990
  } else if (!param.type || param.type === 'any') {
991
- // The host's public takes an untagged array as text (generate-host.ts, kindOf).
991
+ // An untagged array crosses as text (generate-image.ts, kindOf).
992
992
  problems.push(`${where} - argument ${i + 1} is ${pawn} in the include, without a tag: Pawn passes it as text, and a ${type} would arrive empty; tag it, Float:${param.name}[]`);
993
993
  } else {
994
994
  crossing += param.type === 'Float' ? 'F' : '_';
@@ -22,9 +22,14 @@
22
22
  // - the core: the compile's scripts, the patched AssemblyScript and its
23
23
  // Binaryen, the API in as/, the natives table wamrc reads and the WAMR
24
24
  // patch - which also decides whether the server's module loads the .aot.
25
- // A package from the registry is its version: what npm has under a
26
- // version never changes. A folder on this machine (a checkout) is its
27
- // content, so the version and a hash are both kept;
25
+ // A package from the registry is its ABI (scripts/build-identity.ts): the
26
+ // .aot is taken under any core of its line on the same ground that has
27
+ // every import it uses with the same shape, as the server's module of that
28
+ // line loads it - a core's patch release needs no module released again,
29
+ // only one that uses what a later patch added. The .aot keeps the facade
30
+ // of the core it was compiled with, as any plugin built before the patch
31
+ // does. A folder on this machine (a checkout) is its content too, so the
32
+ // version, the ABI and a hash are all kept;
28
33
  // - every module package the compile reads - the module, and the ones it
29
34
  // imports (menu-core reads config-core for its proxy) - the same way;
30
35
  // - the options amxts.config.ts gives them: setup is compiled with them;
@@ -47,12 +52,13 @@ import { createHash } from 'node:crypto';
47
52
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
48
53
  import { join, relative, resolve, sep } from 'node:path';
49
54
  import ts from 'typescript';
50
- import { coreVersion } from './build-identity';
55
+ import { ABI_SECTION, abiIdentity, abiLine, coreVersion, importName, importShapes, releaseLine } from './build-identity';
51
56
  import { compileToMachineCode, compileToWasm } from './compile';
52
57
  import { codeFiles } from './compile-cache';
53
58
  import { includeForward, nativesBeside } from './plugin-natives';
54
59
  import { CORE_DIR, CORE_PLUGINS, loadProject, optionsOf, shared, sourcesFor } from './project';
55
60
  import { moduleSurface } from './shared-modules';
61
+ import { readSection } from './source-map';
56
62
  import { SYSTEM_NAME, SYSTEMS, wamrcPath } from './system';
57
63
  import { c, log, since } from './ui';
58
64
 
@@ -60,7 +66,7 @@ import { c, log, since } from './ui';
60
66
  export const PREBUILT_DIR = 'prebuilt';
61
67
  const MANIFEST = 'manifest.json';
62
68
  /** Goes up when the manifest changes shape: a build reads only its own. */
63
- const FORMAT = 1;
69
+ const FORMAT = 2;
64
70
  const CORE_NAME = '@amxts/core';
65
71
 
66
72
  /** A package as a prebuilt module was compiled with it. */
@@ -68,6 +74,8 @@ interface Built {
68
74
  version: string;
69
75
  /** Its content: the code files, TypeScript without comments. */
70
76
  hash: string;
77
+ /** The core's: the ABI the .aot carries. */
78
+ abi?: string;
71
79
  }
72
80
 
73
81
  export interface PrebuiltManifest {
@@ -152,9 +160,9 @@ function coreHash(): string {
152
160
  }
153
161
 
154
162
  /** The core or a module package the project has, by name, as a prebuilt module is checked against it. */
155
- function installed(name: string, sources: Sources): { version: string; dir: string; hash: () => string } | null {
163
+ function installed(name: string, sources: Sources): { version: string; dir: string; hash: () => string; abi?: string } | null {
156
164
  if (name === CORE_NAME) {
157
- return { version: coreVersion(), dir: CORE_DIR, hash: coreHash };
165
+ return { version: coreVersion(), dir: CORE_DIR, hash: coreHash, abi: abiIdentity() };
158
166
  }
159
167
  const pkg = sources.project.packages.find(each => each.name === name);
160
168
  return pkg ? { version: pkg.version, dir: pkg.dir, hash: () => packageHash(pkg) } : null;
@@ -223,7 +231,8 @@ function mismatch(manifest: PrebuiltManifest, pkg: ModulePackage, sources: Sourc
223
231
  for (const [name, built] of Object.entries(manifest.from)) {
224
232
  const have = installed(name, sources);
225
233
  if (!have) return `${named} was built with ${name}, which the project does not have`;
226
- if (have.version !== built.version) return `${named} was built for ${name} ${built.version}, the project has ${have.version}`;
234
+ const other = have.abi ? otherAbi(built.abi ?? '', have.abi) : have.version !== built.version && `${name} ${built.version}, the project has ${have.version}`;
235
+ if (other) return `${named} was built for ${other}`;
227
236
  if (!fromRegistry(have.dir) && have.hash() !== built.hash) {
228
237
  return `${named} was built from another ${name} ${built.version} than the one in ${posix(relative(sources.project.dir, have.dir)) || '.'}`;
229
238
  }
@@ -235,11 +244,31 @@ function mismatch(manifest: PrebuiltManifest, pkg: ModulePackage, sources: Sourc
235
244
  }
236
245
  }
237
246
  const own = resolve(sources.project.pluginsDir) !== resolve(CORE_PLUGINS);
238
- const shadow = own ? manifest.places.find(place => existsSync(join(sources.project.pluginsDir, place))) : undefined;
247
+ const shadow = own ? manifest.places.find(place => sources.ownFile(place)) : undefined;
239
248
  if (shadow) return `${posix(relative(sources.project.dir, join(sources.project.pluginsDir, shadow)))} takes the place of a file ${named} was built from`;
240
249
  return null;
241
250
  }
242
251
 
252
+ /**
253
+ * How the ABI a module was built for (the identity and its imports, as its
254
+ * .aot carries them) differs from the project's - `amxts 0.2, the project
255
+ * has 0.3`, both whole within a line, an import of a later patch or of
256
+ * another shape - or false when the server's module would load it: a module
257
+ * built by one patch serves every patch of its line that has its imports.
258
+ */
259
+ function otherAbi(built: string, have: string): string | false {
260
+ const [identity, ...imports] = built.split('\n');
261
+ if (abiLine(identity) !== abiLine(have)) {
262
+ const line = (abi: string) => releaseLine(abi.slice(0, abi.indexOf('+')));
263
+ return line(identity) === line(have) ? `amxts ${identity}, the project has ${have}` : `amxts ${line(identity)}, the project has ${line(have)}`;
264
+ }
265
+ const shapes = importShapes();
266
+ const misfit = imports.find(shape => shapes.get(importName(shape)) !== shape);
267
+ if (!misfit) return false;
268
+ const name = importName(misfit);
269
+ return shapes.has(name) ? `another shape of ${name}` : `amxts ${identity.slice(0, identity.indexOf('+'))} or later (it uses ${name}), the project has ${coreVersion()}`;
270
+ }
271
+
243
272
  /** The `new Forward<...>("name")` calls of a package whose arguments are not all text: their names. */
244
273
  function typedForwards(pkg: ModulePackage): string[] {
245
274
  const names: string[] = [];
@@ -261,6 +290,7 @@ async function prebuild(pkg: ModulePackage, sources: Sources): Promise<string> {
261
290
  rmSync(dir, { recursive: true, force: true });
262
291
  mkdirSync(dir, { recursive: true });
263
292
  const owner = sources.ownerSource(pkg);
293
+ const core = installed(CORE_NAME, sources)!;
264
294
  const plugin = { source: owner, output: '', root: CORE_PLUGINS, wamrc: wamrcPath(), signatures: join(CORE_DIR, 'runtime/natives.txt'), quick: false };
265
295
  const natives: PluginNative[] = [];
266
296
  const wasm = join(dir, `${pkg.short}.wasm`);
@@ -277,18 +307,19 @@ async function prebuild(pkg: ModulePackage, sources: Sources): Promise<string> {
277
307
  const bytes = readFileSync(output);
278
308
  systems[system] = { file: `${PREBUILT_DIR}/${system}/${pkg.short}.aot`, size: bytes.length, sha256: sha256(bytes) };
279
309
  }
310
+ // The ABI the .aot carries: the identity, and the imports it uses.
311
+ const abi = readSection(new Uint8Array(readFileSync(wasm)), ABI_SECTION) ?? undefined;
280
312
  rmSync(wasm, { force: true });
281
313
 
282
314
  // What the compile read: its places in the tree, and the packages they are in.
283
315
  const reached = sources.reach(join(CORE_PLUGINS, 'facade.ts'), sources.reach(join(CORE_PLUGINS, sources.entry(owner))));
284
316
  const places = [...reached].map(place => posix(relative(CORE_PLUGINS, place))).sort();
285
317
  const used = sources.project.modules.filter(each => each === pkg || [...reached].some(place => sources.packageOf(sources.real(place) ?? place) === each));
286
- const core = installed(CORE_NAME, sources)!;
287
318
  const manifest: PrebuiltManifest = {
288
319
  format: FORMAT,
289
320
  module: pkg.name,
290
321
  version: pkg.version,
291
- from: Object.fromEntries([[CORE_NAME, { version: core.version, hash: core.hash() }], ...used.map(each => [each.name, { version: each.version, hash: packageHash(each) }])]),
322
+ from: Object.fromEntries([[CORE_NAME, { version: core.version, hash: core.hash(), abi }], ...used.map(each => [each.name, { version: each.version, hash: packageHash(each) }])]),
292
323
  options: Object.fromEntries(used.map(each => [each.name, optionsOf(sources.project, each.definition)])),
293
324
  forwards: Object.fromEntries(used.flatMap(typedForwards).map(name => [name, declarationOf(name)])),
294
325
  places,
@@ -53,6 +53,7 @@ import { createRequire } from 'node:module';
53
53
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import ts from 'typescript';
56
+ import { serverFolder } from '../src/system.mjs';
56
57
  import { coreImports, importedName, importTable, withAutoImports } from './auto-imports';
57
58
  import { includeDirs } from './includes';
58
59
  import { existsSync, readdirSync, readFileSync, statSync } from './tracked-fs';
@@ -667,9 +668,56 @@ export function coreEntryOf(place: string): string {
667
668
  export function projectPlugins(project: Project): string[] {
668
669
  const core = resolve(project.pluginsDir) === resolve(CORE_PLUGINS);
669
670
  if (core && resolve(project.dir) !== resolve(CORE_DIR)) return [];
671
+ const stale = new Set(staleCopies(project));
670
672
  return readdirSync(project.pluginsDir)
671
673
  .filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !(core && NOT_PLUGINS.has(f)))
672
- .map(f => join(project.pluginsDir, f));
674
+ .map(f => join(project.pluginsDir, f))
675
+ .filter(f => !stale.has(f));
676
+ }
677
+
678
+ /** A server's addons/amxts: the module's files, written over on every start. */
679
+ export const SERVER_FOLDER = /[\\/]addons[\\/]amxts$/i;
680
+
681
+ /** The server's addons/amxts that AMXTS_SERVER names, or '' when it names none. */
682
+ export function serverDir(): string {
683
+ try {
684
+ const dir = serverFolder(process.env.AMXTS_SERVER ?? '');
685
+ return dir && resolve(dir);
686
+ } catch {
687
+ return '';
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Copies of the core's own API at the top of the project's plugins folder,
693
+ * left there by an older amxts: a file named as one of the core's as/ (or
694
+ * the editor's imports.d.ts) whose first line is the core's - a generator's
695
+ * header or that file's own first line. An author's file of the same name
696
+ * starts otherwise and stays a plugin. A server's addons/amxts/plugins of
697
+ * an older amxts holds them too: its module wrote them there.
698
+ */
699
+ export function staleCopies(project: Project): string[] {
700
+ const dir = resolve(project.pluginsDir);
701
+ const parent = dirname(dir);
702
+ if (!existsSync(dir) || dir === resolve(CORE_PLUGINS) || SERVER_FOLDER.test(parent) || parent === serverDir()) return [];
703
+ coreFirstLines ??= new Map([...readdirSync(CORE_PLUGINS).filter(f => f.endsWith('.ts')).map(f => [f, firstLine(join(CORE_PLUGINS, f))] as const), ['imports.d.ts', '// GENERATED by ']]);
704
+ const core = coreFirstLines;
705
+ return readdirSync(dir)
706
+ .filter((f) => {
707
+ const header = core.get(f);
708
+ if (header === undefined) return false;
709
+ const line = firstLine(join(dir, f));
710
+ return line.startsWith('// GENERATED by ') || line === header;
711
+ })
712
+ .sort()
713
+ .map(f => join(dir, f));
714
+ }
715
+
716
+ /** The first line of each file at the top of the core's as/, read once. */
717
+ let coreFirstLines: Map<string, string> | undefined;
718
+
719
+ function firstLine(path: string): string {
720
+ return readFileSync(path, 'utf8').split('\n', 1)[0].trimEnd();
673
721
  }
674
722
 
675
723
  /** The modules that run on the server - each in its owner plugin: every one but a library. */
@@ -747,7 +795,12 @@ export class Sources {
747
795
  /** A plugin that imports a module package the config does not list. */
748
796
  readonly problems: string[] = [];
749
797
 
750
- constructor(readonly root: string, readonly project: Project) {}
798
+ /** Copies of the core's API an older amxts left in the plugins folder: the core's files are under them. */
799
+ private readonly stale: Set<string>;
800
+
801
+ constructor(readonly root: string, readonly project: Project) {
802
+ this.stale = new Set(staleCopies(project));
803
+ }
751
804
 
752
805
  /**
753
806
  * Whether a file is a plugin's code, which auto-imports reach: the
@@ -792,14 +845,20 @@ export class Sources {
792
845
  return this.project.modules.find(pkg => path === pkg.module || path === pkg.natives || ofPackage(pkg, path)) ?? null;
793
846
  }
794
847
 
848
+ /** The project's own file at this place under its plugins folder, or null: an old copy of the core's API is not one. */
849
+ ownFile(place: string): string | null {
850
+ const own = join(this.project.pluginsDir, place);
851
+ return existsSync(own) && !this.stale.has(own) ? own : null;
852
+ }
853
+
795
854
  /** The file on disk at this place in the tree, or null. */
796
855
  real(path: string): string | null {
797
856
  const rel = this.rel(path);
798
857
  if (!inside(this.root, resolve(path))) return existsSync(path) ? resolve(path) : null;
799
858
 
800
859
  if (this.project.pluginsDir !== resolve(this.root)) {
801
- const own = join(this.project.pluginsDir, rel);
802
- if (existsSync(own)) return own;
860
+ const own = this.ownFile(rel);
861
+ if (own) return own;
803
862
  }
804
863
  const core = resolve(this.root, rel);
805
864
  if (existsSync(core)) return core;
@@ -1,13 +1,13 @@
1
- // The game events reapi alone delivers - ReGameDLL's and ReHLDS's own
2
- // functions - and the build's refusal of a listener for one nothing else
1
+ // The game events only ReGameDLL's and ReHLDS's hookchains deliver - their
2
+ // own functions - and the build's refusal of a listener for one nothing else
3
3
  // hears, in a project for plain HLDS.
4
4
  //
5
- // A server without reapi hears every other game event through Ham Sandwich
6
- // (as/hooks.ts picks it once, by hasModule), and many of these through
7
- // another stock hook (scripts/hlds-events.ts); a listener for one of the rest
8
- // is only a line in its console. A project whose amxts.config.ts says
9
- // `target: "hlds"` has said there is no reapi, so the build stops at such a
10
- // listener instead, with the file and the line.
5
+ // A server without them hears every other game event through the entity's
6
+ // function in its class (as/hooks.ts picks it once), and many of these
7
+ // through another stock hook (scripts/hlds-events.ts); a listener for one of
8
+ // the rest is only a line in its console. A project whose amxts.config.ts
9
+ // says `target: "hlds"` has said there is neither, so the build stops at
10
+ // such a listener instead, with the file and the line.
11
11
  import { join } from 'node:path';
12
12
  // @ts-ignore - shipped as JavaScript, with types beside it we do not need here
13
13
  import * as asc from '../runtime/deps/assemblyscript/dist/assemblyscript.js';
@@ -76,7 +76,7 @@ export function reapiListeners(sources: any[], events = unheardEvents()): string
76
76
  const event = gameEventOf(node);
77
77
  if (event === undefined || !events.has(event)) return;
78
78
  const line = source.text.slice(0, node.range.start).split('\n').length;
79
- found.push(`${source.normalizedPath}:${line}: "${event}" needs ReAPI, and amxts.config.ts's target is "hlds" - nothing on plain HLDS hears it. Listen for another event, or set target: "rehlds"`);
79
+ found.push(`${source.normalizedPath}:${line}: "${event}" is ReGameDLL's or ReHLDS's own, and amxts.config.ts's target is "hlds" - nothing on plain HLDS hears it. Listen for another event, or set target: "rehlds"`);
80
80
  });
81
81
  }
82
82
  return found;
@@ -0,0 +1,145 @@
1
+ // env(): the settings a plugin reads of the server's environment and of its
2
+ // addons/amxts/.env, and the ones a plugin cannot start without.
3
+ //
4
+ // const token = env("KZ_MAP_TOKEN"); // required text: the plugin does not start without it
5
+ // const limit = env("KZ_MAX_RECORDS", 100); // a number, 100 when the server has none
6
+ // const debug = env("KZ_DEBUG", false); // an on/off switch
7
+ //
8
+ // Each call's name, and the kind and value of its default, go into the
9
+ // plugin's .aot as the section amxts.env, a line each: `KZ_MAP_TOKEN` for a
10
+ // required one, `KZ_MAX_RECORDS number 100`, `KZ_DEBUG boolean false`,
11
+ // `KZ_MIRROR string https://...`. The module refuses the plugin while a
12
+ // required one is nowhere or one is there and not of its kind
13
+ // (runtime/src/module.cpp, MissingEnv; envProblems here is the same), and
14
+ // `amxts dev` warns of one the server's .env lacks. The name and the default
15
+ // are written out in the call: the build reads them there. The module reads
16
+ // .env as parseDotenv does.
17
+ import ts from 'typescript';
18
+
19
+ /** The custom section of a plugin that lists the variables it reads: wamrc copies it into the .aot. */
20
+ export const ENV_SECTION = 'amxts.env';
21
+
22
+ /** What a plugin imports the facade's env() from, once the build has rewritten its imports. */
23
+ const FACADE = new Set(['~/facade', '@amxts/core']);
24
+
25
+ /** A number as env() takes one: decimal, a fraction and an exponent allowed. */
26
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
27
+
28
+ /** The words of an on/off switch, in any case. */
29
+ const SWITCH = new Set(['1', '0', 'true', 'false', 'yes', 'no', 'on', 'off']);
30
+
31
+ /**
32
+ * A .env file's variables: `KEY=value` lines, `#` comments, a value in
33
+ * quotes kept as it is - `#` and spaces too - and one without them cut at a
34
+ * ` #` comment and trimmed. A line without `=` is no variable.
35
+ */
36
+ export function parseDotenv(text: string): Map<string, string> {
37
+ const variables = new Map<string, string>();
38
+ for (const raw of text.split('\n')) {
39
+ const line = raw.trim();
40
+ const at = line.indexOf('=');
41
+ if (line.startsWith('#') || at <= 0) continue;
42
+ const value = line.slice(at + 1).trim();
43
+ const quote = value[0];
44
+ const end = quote === '"' || quote === '\'' ? value.indexOf(quote, 1) : -1;
45
+ variables.set(line.slice(0, at).trim(), end > 0 ? value.slice(1, end) : value.replace(/\s+#.*$/, ''));
46
+ }
47
+ return variables;
48
+ }
49
+
50
+ /** One call of the facade's env(): its section line - null when its name or default is not written out - and its line in the file. */
51
+ export interface EnvCall {
52
+ entry: string | null;
53
+ line: number;
54
+ }
55
+
56
+ /** A default as the section writes it - `number 100`, `boolean false`, `string text` - or null when it is not written out. */
57
+ function defaultOf(node: ts.Expression): string | null {
58
+ if (ts.isStringLiteralLike(node)) return `string ${node.text}`;
59
+ if (ts.isNumericLiteral(node)) return `number ${node.text}`;
60
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand)) return `number -${node.operand.text}`;
61
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return `boolean ${node.getText()}`;
62
+ return null;
63
+ }
64
+
65
+ /** The calls of the facade's env() in a file, by the name it imports env() under. */
66
+ export function envCallsOf(text: string): EnvCall[] {
67
+ if (!text.includes('env')) return [];
68
+ const source = ts.createSourceFile('file.ts', text, ts.ScriptTarget.Latest, true);
69
+ const local = source.statements.flatMap((statement) => {
70
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !FACADE.has(statement.moduleSpecifier.text)) return [];
71
+ const bindings = statement.importClause?.namedBindings;
72
+ return bindings && ts.isNamedImports(bindings) ? bindings.elements.filter(each => (each.propertyName ?? each.name).text === 'env').map(each => each.name.text) : [];
73
+ })[0];
74
+ if (!local) return [];
75
+
76
+ const calls: EnvCall[] = [];
77
+ const visit = (node: ts.Node) => {
78
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === local) {
79
+ const [name, fallback] = node.arguments;
80
+ const kind = fallback ? defaultOf(fallback) : '';
81
+ calls.push({
82
+ entry: name && ts.isStringLiteralLike(name) && kind !== null ? `${name.text}${kind ? ` ${kind}` : ''}` : null,
83
+ line: source.getLineAndCharacterOfPosition(node.getStart()).line + 1,
84
+ });
85
+ }
86
+ ts.forEachChild(node, visit);
87
+ };
88
+ visit(source);
89
+ return calls;
90
+ }
91
+
92
+ /**
93
+ * One compile's section lines: `read` goes into the compile's readFile with
94
+ * every file the plugin reaches, `entries` gives them after, one a name, and
95
+ * `problems` each call whose name or default is not written out; a new one
96
+ * for every compile.
97
+ */
98
+ export function envBuild() {
99
+ const entries = new Map<string, string>();
100
+ const problems: string[] = [];
101
+ return {
102
+ read(file: string, text: string): void {
103
+ for (const call of envCallsOf(text)) {
104
+ if (call.entry === null) {
105
+ problems.push(`${file.replace(/\\/g, '/')}:${call.line}: env() takes its name and its default written out - env("MYPLUGIN_TOKEN"), env("MYPLUGIN_LIMIT", 100), env("MYPLUGIN_DEBUG", false), env("MYPLUGIN_URL", "https://...") - the build reads what a plugin needs from the call`);
106
+ continue;
107
+ }
108
+ const name = call.entry.split(' ', 1)[0];
109
+ // Required wherever one call has no default.
110
+ if (!entries.has(name) || call.entry === name) entries.set(name, call.entry);
111
+ }
112
+ },
113
+ entries: () => [...entries.keys()].sort().map(name => entries.get(name)!),
114
+ problems,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * What the section's lines need that `variables` has not, as the module's
120
+ * line names it: `KZ_MAP_TOKEN` for a required one missing,
121
+ * `KZ_MAX_RECORDS as a number` for one there and of another kind.
122
+ */
123
+ export function envProblems(entries: string[], variables: Map<string, string>): string[] {
124
+ return entries.flatMap((entry) => {
125
+ const [name, kind] = entry.split(' ');
126
+ const value = variables.get(name);
127
+ if (value === undefined) return kind ? [] : [name];
128
+ if (kind === 'number' && !NUMBER.test(value)) return [`${name} as a number`];
129
+ if (kind === 'boolean' && !SWITCH.has(value.toLowerCase())) return [`${name} as a boolean`];
130
+ return [];
131
+ });
132
+ }
133
+
134
+ /** The section of a plugin's .aot by its name, as the module reads it (AotSection); null when it has none. */
135
+ export function aotSection(aot: Uint8Array, wanted: string): string | null {
136
+ const view = new DataView(aot.buffer, aot.byteOffset, aot.byteLength);
137
+ const name = new TextEncoder().encode(`${wanted}\0`);
138
+ for (let at = 8; at + 8 <= aot.length; at = (at + 8 + view.getUint32(at + 4, true) + 3) & ~3) {
139
+ const length = view.getUint32(at + 4, true);
140
+ const body = at + 8;
141
+ if (view.getUint32(at, true) !== 100 || length < 6 + name.length || view.getUint32(body, true) !== 0 || view.getUint16(body + 4, true) !== name.length) continue;
142
+ if (name.every((byte, i) => aot[body + 6 + i] === byte)) return new TextDecoder().decode(aot.subarray(body + 6 + name.length, body + length));
143
+ }
144
+ return null;
145
+ }
@@ -18,7 +18,8 @@
18
18
  // What crosses, and how:
19
19
  //
20
20
  // - number, boolean, string (and a union of string literals, which is a
21
- // string), arrays of them, `T | null`;
21
+ // string), arrays of them, `T | null`, a `Record<string, T>` - its keys
22
+ // and values in order; a rest parameter as its array;
22
23
  // - Player, by its id;
23
24
  // - an object the module hands out and takes back - it is returned by one
24
25
  // exported function and taken by another: a config, a section - or hands
@@ -100,6 +101,7 @@ type Wire
100
101
  | { kind: 'player' }
101
102
  | { kind: 'null'; of: Wire }
102
103
  | { kind: 'array'; of: Wire }
104
+ | { kind: 'dict'; of: Wire }
103
105
  | { kind: 'fn'; params: Wire[]; result: Wire | null; sig: number }
104
106
  | { kind: 'record'; cls: ClassInfo }
105
107
  | { kind: 'handle'; cls: ClassInfo };
@@ -132,6 +134,8 @@ interface ExportedFunction {
132
134
  paramNames: string[];
133
135
  wires: Wire[];
134
136
  result: Wire | null;
137
+ /** Its last parameter is a rest parameter, `...names: string[]`: the owner spreads the array into it. */
138
+ rest?: boolean;
135
139
  }
136
140
 
137
141
  export interface ModuleAnalysis {
@@ -434,6 +438,11 @@ function describe(program: any, name: string): ModuleAnalysis {
434
438
  const of = toWire(cls.typeArguments[0], context);
435
439
  return of ? { kind: 'array', of } : null;
436
440
  }
441
+ // A Record of text keys, `{ KZ_CP: checkpoint }`: its keys and values, in order.
442
+ if (program.resolver.isRecordClass(cls) && String(cls.typeArguments[0]) === '~lib/string/String') {
443
+ const of = toWire(cls.typeArguments[1], context);
444
+ return of ? { kind: 'dict', of } : null;
445
+ }
437
446
  if (cls.internalName === '~lib/~/facade/Player') return { kind: 'player' };
438
447
  const info = classInfo(cls, context);
439
448
  if (!info) return null;
@@ -459,13 +468,12 @@ function describe(program: any, name: string): ModuleAnalysis {
459
468
  const params: string[] = [];
460
469
  const paramNames: string[] = [];
461
470
  const wires: Wire[] = [];
471
+ let rest = false;
462
472
  declaration.signature.parameters.forEach((param: any, i: number) => {
463
473
  const paramName = param.name.text;
464
474
  const pcontext = where(`${context} - parameter "${paramName}"`);
465
- if (param.parameterKind === as.ParameterKind.Rest) {
466
- problems.push(`${pcontext}: a rest parameter cannot cross - take an array`);
467
- return;
468
- }
475
+ // A rest parameter crosses as the array it is.
476
+ if (param.parameterKind === as.ParameterKind.Rest) rest = true;
469
477
  if (param.initializer && !plainDefault(param.initializer)) {
470
478
  problems.push(`${pcontext}: its default is computed - the proxy evaluates it in the other plugin, so it has to be a literal`);
471
479
  return;
@@ -484,7 +492,7 @@ function describe(program: any, name: string): ModuleAnalysis {
484
492
  }
485
493
  const result = returned === 'void' ? null : toWire(returns, where(`${context} - its result`));
486
494
  if (returned !== 'void' && !result) return null;
487
- return { name: fnName, params, paramNames, wires, result };
495
+ return { name: fnName, params, paramNames, wires, result, rest };
488
496
  }
489
497
 
490
498
  for (const { name: fnName, element, instance } of exported) {
@@ -573,6 +581,7 @@ function wireKey(wire: Wire): string {
573
581
  case 'player': return 'player';
574
582
  case 'null': return `${wireKey(wire.of)}?`;
575
583
  case 'array': return `${wireKey(wire.of)}[]`;
584
+ case 'dict': return `{${wireKey(wire.of)}}`;
576
585
  case 'fn': return `(${wire.params.map(wireKey).join(',')})=>${wire.result ? wireKey(wire.result) : 'void'}`;
577
586
  case 'record': return `rec ${wire.cls.internal}`;
578
587
  case 'handle': return `handle ${wire.cls.internal}`;
@@ -609,6 +618,7 @@ class Codec {
609
618
  case 'player': return '__Player';
610
619
  case 'null': return wire.of.kind === 'fn' ? `(${this.type(wire.of)}) | null` : `${this.type(wire.of)} | null`;
611
620
  case 'array': return `Array<${this.type(wire.of)}>`;
621
+ case 'dict': return `Record<string, ${this.type(wire.of)}>`;
612
622
  case 'fn': return `(${wire.params.map((p, i) => `a${i}: ${this.type(p)}`).join(', ')}) => ${wire.result ? this.type(wire.result) : 'void'}`;
613
623
  case 'record':
614
624
  case 'handle': return this.className(wire.cls);
@@ -677,6 +687,12 @@ class Codec {
677
687
  lines.push(`function __r${n}(r: __Reader): ${T} {\n\tconst count = r.i32();\n\tconst list = new Array<${this.type(wire.of)}>();\n\tfor (let i: i32 = 0; i < count; i++) list.push(__r${inner}(r));\n\treturn list;\n}`);
678
688
  break;
679
689
  }
690
+ case 'dict': {
691
+ const inner = this.id(wire.of);
692
+ lines.push(`function __w${n}(w: __Writer, v: ${T}): void {\n\tconst keys = Object.keys(v);\n\tw.i32(keys.length);\n\tfor (let i: i32 = 0; i < keys.length; i++) {\n\t\tw.str(keys[i]);\n\t\t__w${inner}(w, v[keys[i]]);\n\t}\n}`);
693
+ lines.push(`function __r${n}(r: __Reader): ${T} {\n\tconst count = r.i32();\n\tconst record: ${T} = {};\n\tfor (let i: i32 = 0; i < count; i++) {\n\t\tconst key = r.str();\n\t\trecord[key] = __r${inner}(r);\n\t}\n\treturn record;\n}`);
694
+ break;
695
+ }
680
696
  case 'fn':
681
697
  lines.push(`function __w${n}(w: __Writer, v: ${T}): void { w.i32(__sendFunction(changetype<usize>(v), ${wire.sig}, __invoke)); }`);
682
698
  lines.push(`function __r${n}(r: __Reader): ${T} { return changetype<${T}>(__receiveFunction(r, changetype<usize>(__stub${wire.sig}))); }`);
@@ -875,7 +891,7 @@ export function serveSource(analysis: ModuleAnalysis): string {
875
891
 
876
892
  /** A call of `fn` on `target` - the module, or a handle's object read first. */
877
893
  const callCase = (op: number, fn: ExportedFunction, target: string, before: string[]) => {
878
- const args = fn.paramNames.map((_, i) => `a${i}`).join(', ');
894
+ const args = fn.paramNames.map((_, i) => `${fn.rest && i === fn.paramNames.length - 1 ? '...' : ''}a${i}`).join(', ');
879
895
  const lines = [
880
896
  ...before,
881
897
  ...fn.wires.map((wire, i) => `\t\t\tconst a${i} = ${codec.read(wire, 'r')};`),
package/scripts/system.ts CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { executable, MODULE_FILE } from '../src/system.mjs';
8
8
 
9
9
  export type { ServerSystem, System } from '../src/system.mjs';
10
- export { describeSystem, detectServerSystem, executable, HOST_SYSTEM, manifestName, MODULE_FILE, moduleVersion, parseSystem, serverFiles, serverFolder, serverImage, serverSystem, SYSTEM_NAME, SYSTEMS, TARGET_ABI, WAMRC_PACKAGE, wamrcPath } from '../src/system.mjs';
10
+ export { describeSystem, detectServerSystem, executable, HOST_SYSTEM, manifestName, MODULE_FILE, moduleAbiOf, moduleVersion, parseSystem, serverFiles, serverFolder, serverImage, serverSystem, SYSTEM_NAME, SYSTEMS, TARGET_ABI, WAMRC_PACKAGE, wamrcPath } from '../src/system.mjs';
11
11
 
12
12
  const CORE = resolve(fileURLToPath(new URL('..', import.meta.url)));
13
13