@origingame/engine 0.0.0-stage → 0.1.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 (158) hide show
  1. package/bin/og.mjs +25 -0
  2. package/dist/web/origin-engine.d.ts +1233 -0
  3. package/dist/web/origin-engine.js +8628 -0
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
  6. package/dist/web-publish/origin-engine-lanes.js +8509 -0
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +1176 -0
  9. package/dist/web-publish/origin-engine.js +8313 -0
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
  12. package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  13. package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
  14. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  15. package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
  16. package/dist/web-publish-2d/origin-engine.js +8313 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  18. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
  19. package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  20. package/knowledge/README.md +31 -0
  21. package/knowledge/knowledge.json +13 -0
  22. package/knowledge/looks/catalogue.json +1871 -0
  23. package/knowledge/package.json +33 -0
  24. package/knowledge/rules/platform.json +329 -0
  25. package/knowledge/scaffold/README.md +27 -0
  26. package/knowledge/scaffold/distribution.json +48 -0
  27. package/knowledge/scaffold/origin-engine.json +19 -0
  28. package/knowledge/scaffold/template/.origin/setup +14 -0
  29. package/knowledge/scaffold/template/LICENSES.md +23 -0
  30. package/knowledge/scaffold/template/README.md +37 -0
  31. package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
  32. package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
  33. package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
  34. package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
  35. package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
  36. package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
  37. package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
  38. package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
  39. package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
  40. package/knowledge/scaffold/template/gitattributes +22 -0
  41. package/knowledge/scaffold/template/package.json +18 -0
  42. package/knowledge/scaffold/template/project.ogt +25 -0
  43. package/knowledge/scaffold/template/src/main.ts +246 -0
  44. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  45. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  46. package/knowledge/scaffold/template/tsconfig.json +17 -0
  47. package/knowledge/scripts/lib.ts +203 -0
  48. package/knowledge/scripts/mount.ts +210 -0
  49. package/knowledge/scripts/pack.ts +24 -0
  50. package/knowledge/scripts/scaffold.ts +72 -0
  51. package/knowledge/scripts/validate.ts +749 -0
  52. package/knowledge/skills/og-ai/SKILL.md +386 -0
  53. package/knowledge/skills/og-audio/SKILL.md +191 -0
  54. package/knowledge/skills/og-build/SKILL.md +330 -0
  55. package/knowledge/skills/og-core/SKILL.md +285 -0
  56. package/knowledge/skills/og-feel/SKILL.md +385 -0
  57. package/knowledge/skills/og-game/SKILL.md +1479 -0
  58. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  59. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  60. package/knowledge/skills/og-taste/SKILL.md +537 -0
  61. package/knowledge/skills/og-ui/SKILL.md +444 -0
  62. package/knowledge/wiki/ai.md +627 -0
  63. package/knowledge/wiki/anim.md +1187 -0
  64. package/knowledge/wiki/atmosphere.md +636 -0
  65. package/knowledge/wiki/audio.md +732 -0
  66. package/knowledge/wiki/camera.md +711 -0
  67. package/knowledge/wiki/commands.md +357 -0
  68. package/knowledge/wiki/game.md +220 -0
  69. package/knowledge/wiki/i18n.md +213 -0
  70. package/knowledge/wiki/index.md +1027 -0
  71. package/knowledge/wiki/input.md +1714 -0
  72. package/knowledge/wiki/load.md +1280 -0
  73. package/knowledge/wiki/math.md +166 -0
  74. package/knowledge/wiki/nav.md +1154 -0
  75. package/knowledge/wiki/physics.md +3142 -0
  76. package/knowledge/wiki/platform.md +857 -0
  77. package/knowledge/wiki/probe.md +1692 -0
  78. package/knowledge/wiki/reflect.md +285 -0
  79. package/knowledge/wiki/render.md +3511 -0
  80. package/knowledge/wiki/rng.md +393 -0
  81. package/knowledge/wiki/stream.md +376 -0
  82. package/knowledge/wiki/system.md +197 -0
  83. package/knowledge/wiki/systems.md +43 -0
  84. package/knowledge/wiki/terrain.md +727 -0
  85. package/knowledge/wiki/tilemap.md +422 -0
  86. package/knowledge/wiki/time.md +150 -0
  87. package/knowledge/wiki/transform.md +230 -0
  88. package/knowledge/wiki/tween.md +580 -0
  89. package/knowledge/wiki/ui.md +8000 -0
  90. package/knowledge/wiki/world.md +909 -0
  91. package/package.json +68 -4
  92. package/schema/api.schema.json +29675 -0
  93. package/shell/README.md +22 -0
  94. package/shell/brand/README.md +23 -0
  95. package/shell/brand/icon-192.png +0 -0
  96. package/shell/brand/icon-512.png +0 -0
  97. package/shell/brand/mark-on-paper.svg +6 -0
  98. package/shell/brand/origin-mark.svg +5 -0
  99. package/shell/brand/origin-motion.css +251 -0
  100. package/shell/brand/tokens.css +20 -0
  101. package/shell/brand.ts +132 -0
  102. package/shell/page.ts +715 -0
  103. package/shell/platform.ts +203 -0
  104. package/shell/player.ts +238 -0
  105. package/shell/shell.css +185 -0
  106. package/shell/sw.ts +177 -0
  107. package/shell/transport.ts +515 -0
  108. package/shell/wire.ts +369 -0
  109. package/src/e6.ts +69 -0
  110. package/src/env.d.ts +100 -0
  111. package/src/generated/abi.ts +980 -0
  112. package/src/index.ts +193 -0
  113. package/src/node/audio.ts +86 -0
  114. package/src/node/build.ts +109 -0
  115. package/src/node/device.ts +981 -0
  116. package/src/node/doctor.ts +60 -0
  117. package/src/node/index.ts +102 -0
  118. package/src/node/platform.ts +24 -0
  119. package/src/node/playtest-run.ts +249 -0
  120. package/src/node/png.ts +130 -0
  121. package/src/node/polish.ts +976 -0
  122. package/src/node/run.ts +373 -0
  123. package/src/node/web-build.ts +808 -0
  124. package/src/p2.ts +35 -0
  125. package/src/platform-local.ts +211 -0
  126. package/src/playtest.ts +131 -0
  127. package/src/runtime.ts +1509 -0
  128. package/src/systems/cameras.ts +247 -0
  129. package/src/ui-kit/bind.ts +141 -0
  130. package/src/ui-kit/huds.ts +303 -0
  131. package/src/ui-kit/index.ts +36 -0
  132. package/src/ui-kit/menus.ts +187 -0
  133. package/src/ui-kit/settings.ts +131 -0
  134. package/src/ui-kit/store.ts +222 -0
  135. package/src/ui-kit/story.ts +89 -0
  136. package/src/ui-kit/util.ts +106 -0
  137. package/src/web/a11y.ts +429 -0
  138. package/src/web/audio-worklet.ts +193 -0
  139. package/src/web/audio.ts +307 -0
  140. package/src/web/basis.ts +239 -0
  141. package/src/web/gamepad.ts +123 -0
  142. package/src/web/host.ts +166 -0
  143. package/src/web/input.ts +237 -0
  144. package/src/web/lanes.ts +21 -0
  145. package/src/web/play.ts +20 -0
  146. package/src/web/rpc-ring.ts +110 -0
  147. package/src/web/settle.ts +122 -0
  148. package/src/web/shell.ts +643 -0
  149. package/src/web/text-input.ts +248 -0
  150. package/src/web/worker.ts +1002 -0
  151. package/types/bindings.json +13654 -0
  152. package/types/index.d.ts +20090 -0
  153. package/vendor/basis/LICENSE +201 -0
  154. package/vendor/basis/NOTICE +15 -0
  155. package/vendor/basis/README.md +18 -0
  156. package/vendor/basis/basis_transcoder.js +19 -0
  157. package/vendor/basis/basis_transcoder.wasm +0 -0
  158. package/README.md +0 -3
package/src/runtime.ts ADDED
@@ -0,0 +1,1509 @@
1
+ // The TypeScript runtime behind the generated declarations (types/index.d.ts):
2
+ // tokens, declarations, the dispatcher the engine calls, and the world, query
3
+ // and view objects game code is handed. Host-agnostic: a loader binds it to
4
+ // the web host (src/web/) or the native host (src/node/) with `bindHost`.
5
+ //
6
+ // Every engine call goes through the binding symbols of the generated ABI
7
+ // (src/generated/abi.ts). Structured values cross as JSON text; column views
8
+ // cross as whatever the host aliases engine memory with, turned into typed
9
+ // arrays by the binding's `columns`.
10
+
11
+ import bindings from "../types/bindings.json" with { type: "json" };
12
+ import type * as Api from "@origingame/engine";
13
+ import type { Dispatcher, HostAbi } from "./generated/abi.ts";
14
+ import { SYMBOLS } from "./generated/abi.ts";
15
+ import { noPlatform, type PlatformAnswer, type PlatformCall, type PlatformHost } from "./platform-local.ts";
16
+
17
+ // ── host binding ──────────────────────────────────────────────────────────
18
+
19
+ /** A typed array of the column kinds' element types. */
20
+ export type ColumnArray = Float32Array | Float64Array | Uint32Array | Int32Array | Uint8Array;
21
+
22
+ /** How a loader hands the runtime its host. */
23
+ export interface HostBinding {
24
+ /** The exported binding symbols. */
25
+ readonly abi: HostAbi<Uint32Array, unknown>;
26
+ /** The host's name, for messages: `web` or `node`. */
27
+ readonly name: string;
28
+ /**
29
+ * Typed arrays over what `og_archetype_view_column` answered, one per
30
+ * field: `kinds[i]` is the field's column kind, `count` the entity count.
31
+ * `null` when the host refused.
32
+ */
33
+ columns(raw: unknown, kinds: readonly ColumnSpec[], count: number): ColumnArray[] | null;
34
+ /** Whether arrays made earlier still see engine memory (wasm memory growth detaches them). */
35
+ fresh?(array: ColumnArray): boolean;
36
+ }
37
+
38
+ /** A column kind as the binding table declares it. */
39
+ export interface ColumnSpec {
40
+ readonly name: string;
41
+ readonly array: "Float32Array" | "Float64Array" | "Uint32Array" | "Int32Array" | "Uint8Array";
42
+ readonly stride: number;
43
+ /** The stride is each field's capacity (`str(32)`), not the kind's. */
44
+ readonly capacity?: boolean;
45
+ }
46
+
47
+ export const COLUMNS: ReadonlyMap<string, ColumnSpec> = new Map(
48
+ (bindings.columns as ColumnSpec[]).map((c) => [c.name, c]),
49
+ );
50
+
51
+ export const ARRAYS = {
52
+ Float32Array,
53
+ Float64Array,
54
+ Uint32Array,
55
+ Int32Array,
56
+ Uint8Array,
57
+ } as const;
58
+
59
+ let host: HostBinding | null = null;
60
+ const queue: Array<(abi: HostAbi<Uint32Array, unknown>) => void> = [];
61
+
62
+ /** Bind the runtime to a host and flush the declarations made so far. */
63
+ export function bindHost(binding: HostBinding): void {
64
+ const missing = SYMBOLS.filter((s) => typeof (binding.abi as unknown as Record<string, unknown>)[s] !== "function");
65
+ if (missing.length > 0) {
66
+ throw new Error(`the ${binding.name} host lacks ${missing.length} binding symbol(s): ${missing.join(", ")}; rebuild it from the same api.ogt`);
67
+ }
68
+ host = binding;
69
+ publishBuild = null;
70
+ binding.abi.og_host_set_dispatcher(dispatch);
71
+ for (const declare of queue.splice(0)) declare(binding.abi);
72
+ }
73
+
74
+ /** The bound host, for loaders. */
75
+ export function boundHost(): HostBinding | null {
76
+ return host;
77
+ }
78
+
79
+ /**
80
+ * Counts engine calls made through `abi()` and dispatches (game code entered
81
+ * or left): the engine changes a resource only inside one of them, so a
82
+ * resource handle's read made while the count holds is still the store's
83
+ * value (recordHandle).
84
+ */
85
+ let engineCalls = 0;
86
+
87
+ function abi(): HostAbi<Uint32Array, unknown> {
88
+ if (!host) throw new Error("no engine host is loaded: start the game through @origingame/engine's web or node loader");
89
+ // A resource handle's writes reach the engine before anything else does.
90
+ if (dirtyRecords.length > 0) flushRecords();
91
+ engineCalls++;
92
+ return host.abi;
93
+ }
94
+
95
+ /** Run now if a host is bound, else when one is. */
96
+ function declare(call: (abi: HostAbi<Uint32Array, unknown>) => void): void {
97
+ if (host) call(host.abi);
98
+ else queue.push(call);
99
+ }
100
+
101
+ // ── results ───────────────────────────────────────────────────────────────
102
+
103
+ export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string };
104
+
105
+ function parse<T>(text: string): T {
106
+ return JSON.parse(text) as T;
107
+ }
108
+
109
+ function refused<T>(reason: string): Result<T> {
110
+ return { ok: false, reason };
111
+ }
112
+
113
+ function json(value: unknown): string | undefined {
114
+ return value === undefined ? undefined : JSON.stringify(value);
115
+ }
116
+
117
+ // ── tokens and declarations ───────────────────────────────────────────────
118
+
119
+ export interface FieldSpec {
120
+ readonly kind: string;
121
+ readonly default: unknown;
122
+ readonly options: Record<string, unknown>;
123
+ /** A `str` field's capacity in bytes. */
124
+ readonly capacity?: number;
125
+ }
126
+
127
+ export interface Token {
128
+ readonly kind: "component" | "resource" | "event";
129
+ readonly name: string;
130
+ readonly fields?: Readonly<Record<string, FieldSpec>>;
131
+ }
132
+
133
+ /** Field specs, in declaration order, as the host reads `Fields`. */
134
+ function fieldList(fields: Readonly<Record<string, FieldSpec>>): unknown[] {
135
+ return Object.entries(fields).map(([name, spec]) => ({
136
+ name,
137
+ kind: spec?.kind,
138
+ default: spec?.default,
139
+ options: spec?.options ?? {},
140
+ ...(spec?.capacity === undefined ? {} : { capacity: spec.capacity }),
141
+ }));
142
+ }
143
+
144
+ export function defineRecord(kind: "component" | "resource", name: string, fields: Record<string, FieldSpec>): Token {
145
+ const token: Token = Object.freeze({ kind, name, fields: Object.freeze({ ...fields }) });
146
+ declare((a) => {
147
+ if (kind === "component") a.og_reflect_define_component(name, JSON.stringify(fieldList(fields)));
148
+ else a.og_reflect_define_resource(name, JSON.stringify(fieldList(fields)));
149
+ });
150
+ return token;
151
+ }
152
+
153
+ function builtinFields(fields: ReadonlyArray<{ name: string; column?: string; type?: string; default?: unknown }>): Record<string, FieldSpec> {
154
+ const out: Record<string, FieldSpec> = {};
155
+ for (const f of fields) out[f.name] = Object.freeze({ kind: f.column ?? f.type ?? "", default: f.default, options: Object.freeze({}) });
156
+ return out;
157
+ }
158
+
159
+ type Decl = { name: string; schema: string; fields: Array<{ name: string; column?: string; type?: string; default?: unknown }> };
160
+
161
+ /** The engine-owned tokens on `og`, under their schema names (`og.Transform`). */
162
+ export const BUILTINS: Readonly<Record<string, Token>> = Object.freeze(
163
+ Object.fromEntries([
164
+ ...(bindings.components as Decl[]).map((c) => [c.name, Object.freeze({ kind: "component", name: c.schema, fields: Object.freeze(builtinFields(c.fields)) })]),
165
+ ...(bindings.resources as Decl[]).map((r) => [r.name, Object.freeze({ kind: "resource", name: r.schema, fields: Object.freeze(builtinFields(r.fields)) })]),
166
+ ...(bindings.events as Decl[]).map((e) => [e.name, Object.freeze({ kind: "event", name: e.schema })]),
167
+ ]),
168
+ );
169
+
170
+ /** Game functions the engine calls back, by id. */
171
+ const callbacks: Function[] = [];
172
+
173
+ function callback(fn: Function): number {
174
+ callbacks.push(fn);
175
+ return callbacks.length - 1;
176
+ }
177
+
178
+ export interface SystemToken {
179
+ readonly name: string;
180
+ readonly reads: readonly Token[];
181
+ readonly writes: readonly Token[];
182
+ }
183
+
184
+ interface SystemEntry {
185
+ readonly name: string;
186
+ readonly reads: ReadonlySet<string>;
187
+ readonly writes: ReadonlySet<string>;
188
+ }
189
+
190
+ /** Systems by callback id: the dispatcher refuses any other id. */
191
+ const systems = new Map<number, SystemEntry>();
192
+ const systemHandles = new WeakMap<object, number>();
193
+
194
+ export interface SystemDescIn {
195
+ readonly reads?: readonly Token[];
196
+ readonly writes?: readonly Token[];
197
+ readonly without?: readonly Token[];
198
+ readonly after?: readonly string[];
199
+ readonly before?: readonly string[];
200
+ }
201
+
202
+ const tokenRef = (t: Token) => ({ kind: t.kind, name: t.name });
203
+
204
+ export function defineSystem(name: string, desc: SystemDescIn, run: Function): SystemToken {
205
+ const reads = [...(desc.reads ?? [])];
206
+ const writes = [...(desc.writes ?? [])];
207
+ const id = callback(run);
208
+ systems.set(id, {
209
+ name,
210
+ reads: new Set(reads.map((t) => t.name)),
211
+ writes: new Set(writes.map((t) => t.name)),
212
+ });
213
+ const wire = JSON.stringify({
214
+ reads: reads.map(tokenRef),
215
+ writes: writes.map(tokenRef),
216
+ without: (desc.without ?? []).map(tokenRef),
217
+ after: desc.after ?? [],
218
+ before: desc.before ?? [],
219
+ });
220
+ const token = Object.freeze({ name, reads: Object.freeze(reads), writes: Object.freeze(writes) });
221
+ declare((a) => systemHandles.set(token, a.og_system_define_system(name, wire, id)));
222
+ return token;
223
+ }
224
+
225
+ export interface GameToken {
226
+ readonly tick: number;
227
+ readonly systems: readonly SystemToken[];
228
+ }
229
+
230
+ const gameHandles = new WeakMap<object, number>();
231
+
232
+ export interface GameDescIn {
233
+ readonly tick?: number;
234
+ readonly seed?: number;
235
+ readonly scenes?: Readonly<Record<string, string>>;
236
+ readonly scene?: string;
237
+ readonly systems?: readonly SystemToken[];
238
+ readonly resources?: readonly Token[];
239
+ readonly commands?: Readonly<Record<string, Function>>;
240
+ readonly commandHelp?: Readonly<Record<string, { readonly usage?: string; readonly doc?: string }>>;
241
+ readonly maxCatchUp?: number;
242
+ readonly facts?: Function | null;
243
+ readonly start?: Function | null;
244
+ readonly render?: Readonly<Record<string, unknown>>;
245
+ readonly look?: string;
246
+ }
247
+
248
+ export function defineGame(desc: GameDescIn = {}): GameToken {
249
+ const list = [...(desc.systems ?? [])];
250
+ const commands: Record<string, number> = {};
251
+ for (const [name, fn] of Object.entries(desc.commands ?? {})) commands[name] = callback(fn);
252
+ const facts = desc.facts ? callback(desc.facts) : null;
253
+ const start = desc.start ? callback(desc.start) : null;
254
+ const token = Object.freeze({ tick: desc.tick ?? 60, systems: Object.freeze(list) });
255
+ // Built when the host takes it, so the systems' host handles are known.
256
+ declare((a) => {
257
+ // Unset tick, seed, scenes and scene stay null: the engine takes
258
+ // project.ogt's (CreateOptions.project), else 60, 0, none and "main".
259
+ const wire = JSON.stringify({
260
+ tick: desc.tick ?? null,
261
+ seed: desc.seed ?? null,
262
+ scenes: desc.scenes ?? null,
263
+ scene: desc.scene ?? null,
264
+ systems: list.map((s) => systemHandles.get(s) ?? -1),
265
+ resources: (desc.resources ?? []).map((r) => r.name),
266
+ commands,
267
+ commandHelp: desc.commandHelp ?? {},
268
+ maxCatchUp: desc.maxCatchUp ?? 4,
269
+ facts,
270
+ start,
271
+ render: desc.render ?? {},
272
+ look: desc.look ?? "",
273
+ });
274
+ gameHandles.set(token, a.og_game_game(wire));
275
+ });
276
+ return token;
277
+ }
278
+
279
+ export function registerCommand(name: string, handler: Function, desc?: { usage?: string; doc?: string }): Result<void> {
280
+ const id = callback(handler);
281
+ if (!host) {
282
+ declare((a) => void a.og_commands_register(name, id, json(desc)));
283
+ return { ok: true, value: undefined };
284
+ }
285
+ return parse(host.abi.og_commands_register(name, id, json(desc)));
286
+ }
287
+
288
+ // ── views ─────────────────────────────────────────────────────────────────
289
+
290
+ /** Moves on whenever views may have gone stale: after every call into game code, and at every structural change outside systems. */
291
+ let epoch = 0;
292
+
293
+ interface Running {
294
+ readonly system: SystemEntry;
295
+ readonly tick: number;
296
+ }
297
+
298
+ let running: Running | null = null;
299
+
300
+ interface ViewInfo {
301
+ readonly view: number;
302
+ readonly count: number;
303
+ readonly tick: number;
304
+ readonly types: readonly string[];
305
+ }
306
+
307
+ /** A component token's field names and column kinds, worked out once. */
308
+ const layouts = new WeakMap<Token, { names: string[]; kinds: ColumnSpec[] }>();
309
+
310
+ function layoutOf(component: Token): { names: string[]; kinds: ColumnSpec[] } {
311
+ let l = layouts.get(component);
312
+ if (!l) {
313
+ const names = Object.keys(component.fields ?? {});
314
+ const kinds = names.map((n) => {
315
+ const field = component.fields![n]!;
316
+ const kind = COLUMNS.get(field.kind);
317
+ if (!kind) throw new Error(`'${component.name}.${n}' is not a column field`);
318
+ // A str column's stride is the field's capacity: one byte lane per byte.
319
+ return kind.capacity ? { ...kind, stride: field.capacity ?? 0 } : kind;
320
+ });
321
+ layouts.set(component, (l = { names, kinds }));
322
+ }
323
+ return l;
324
+ }
325
+
326
+ /**
327
+ * The view object of a component's arrays. Hosts hand out the same arrays
328
+ * while a column stays put, so the same object is handed back with them:
329
+ * a system's views cost no allocation from one tick to the next. The object
330
+ * is plain data; the ArchetypeView is what refuses stale use.
331
+ */
332
+ const views = new WeakMap<Token, Array<{ arrays: ColumnArray[]; view: Record<string, ColumnArray> }>>();
333
+
334
+ function viewOf(component: Token, names: string[], arrays: ColumnArray[]): Record<string, ColumnArray> {
335
+ let list = views.get(component);
336
+ if (!list) views.set(component, (list = []));
337
+ for (const entry of list) {
338
+ if (entry.arrays.length === arrays.length && entry.arrays.every((a, i) => a === arrays[i])) return entry.view;
339
+ }
340
+ const view: Record<string, ColumnArray> = {};
341
+ for (let i = 0; i < names.length; i++) view[names[i]!] = arrays[i]!;
342
+ if (list.length >= 64) list.shift();
343
+ list.push({ arrays, view });
344
+ return view;
345
+ }
346
+
347
+ class ArchetypeViewImpl {
348
+ readonly #info: ViewInfo;
349
+ readonly #epoch: number;
350
+ readonly #system: SystemEntry | null;
351
+ #entities: Uint32Array | null = null;
352
+ readonly #columns = new Map<string, object>();
353
+
354
+ constructor(info: ViewInfo, system: SystemEntry | null) {
355
+ this.count = info.count;
356
+ this.#info = info;
357
+ this.#epoch = epoch;
358
+ this.#system = system;
359
+ }
360
+
361
+ #check(): void {
362
+ if (this.#epoch !== epoch) {
363
+ const owner = this.#system ? `system '${this.#system.name}'` : "a call outside systems";
364
+ throw new Error(`stale ArchetypeView: it belonged to tick ${this.#info.tick}, ${owner}; views last for the system call they were handed to (outside systems, until the next structural change), so take them again from q.archetypes()`);
365
+ }
366
+ }
367
+
368
+ /** The entity count: plain data (a hot loop's bound), so reading it never throws. */
369
+ readonly count: number;
370
+
371
+ get tick(): number {
372
+ this.#check();
373
+ return this.#info.tick;
374
+ }
375
+
376
+ get entities(): Uint32Array {
377
+ this.#check();
378
+ return (this.#entities ??= abi().og_archetype_view_get_entities(this.#info.view));
379
+ }
380
+
381
+ entity(index: number): number {
382
+ const e = this.entities;
383
+ return e[index * 2]! + e[index * 2 + 1]! * 4294967296;
384
+ }
385
+
386
+ has(component: Token): boolean {
387
+ this.#check();
388
+ return this.#info.types.includes(component.name);
389
+ }
390
+
391
+ column(component: Token): object {
392
+ this.#check();
393
+ const cached = this.#columns.get(component.name) as Record<string, ColumnArray> | undefined;
394
+ // A grown wasm memory detached the arrays: make them again.
395
+ if (cached && !(host!.fresh && Object.values(cached).some((a) => !host!.fresh!(a)))) return cached;
396
+ const s = this.#system;
397
+ if (s && !s.reads.has(component.name) && !s.writes.has(component.name)) {
398
+ throw new Error(`system '${s.name}' did not declare '${component.name}' in its reads or writes; declare it to get its columns`);
399
+ }
400
+ if (!this.#info.types.includes(component.name)) {
401
+ throw new Error(`this archetype has no '${component.name}'; check a.has(${component.name}) first`);
402
+ }
403
+ const { names, kinds } = layoutOf(component);
404
+ const b = host!;
405
+ const arrays = b.columns(abi().og_archetype_view_column(this.#info.view, component.name), kinds, this.#info.count);
406
+ if (!arrays || arrays.length !== names.length) {
407
+ throw new Error(`the ${b.name} host refused the columns of '${component.name}' (tick ${this.#info.tick})`);
408
+ }
409
+ const view = viewOf(component, names, arrays);
410
+ this.#columns.set(component.name, view);
411
+ return view;
412
+ }
413
+ }
414
+
415
+ class QueryImpl {
416
+ readonly #handle: number;
417
+ readonly #system: SystemEntry | null;
418
+ #cached: ArchetypeViewImpl[] | null = null;
419
+ #cachedEpoch = -1;
420
+
421
+ constructor(handle: number, system: SystemEntry | null) {
422
+ this.#handle = handle;
423
+ this.#system = system;
424
+ }
425
+
426
+ archetypes(): readonly ArchetypeViewImpl[] {
427
+ if (this.#cached && this.#cachedEpoch === epoch) return this.#cached;
428
+ const list = parse<ViewInfo[]>(abi().og_query_archetypes(this.#handle));
429
+ this.#cached = list.map((info) => new ArchetypeViewImpl(info, this.#system));
430
+ this.#cachedEpoch = epoch;
431
+ return this.#cached;
432
+ }
433
+
434
+ count(): number {
435
+ return abi().og_query_count(this.#handle);
436
+ }
437
+ }
438
+
439
+ // ── declared access ───────────────────────────────────────────────────────
440
+
441
+ /** Whether the bound host is a publish build (the probe's hello says), learnt when the first engine is made. */
442
+ let publishBuild: boolean | null = null;
443
+
444
+ function learnBuild(engine: number): void {
445
+ publishBuild ??= (parse<{ build?: string } | null>(abi().og_engine_probe_hello(engine))?.build ?? "dev") === "publish";
446
+ }
447
+
448
+ /**
449
+ * Inside a system, a resource or event it did not declare (`read`: among its
450
+ * reads or writes; `write`: among its writes). Outside a publish build that
451
+ * throws with the system's name and the record, failing the tick; a publish
452
+ * build lets the engine refuse it, which logs it once per system.
453
+ */
454
+ function checkAccess(access: "read" | "write", name: string): boolean {
455
+ const r = running;
456
+ if (!r) return true;
457
+ const s = r.system;
458
+ if (access === "write" ? s.writes.has(name) : s.reads.has(name) || s.writes.has(name)) return true;
459
+ if (publishBuild === true) return false;
460
+ const list = access === "write" ? "writes" : "reads or writes";
461
+ throw new Error(`system '${s.name}' did not declare '${name}' among its ${list} (${access} refused): add it to the system's ${list}`);
462
+ }
463
+
464
+ // ── resources as live values ──────────────────────────────────────────────
465
+ //
466
+ // A resource handle is a typed view of the resource's packed record: the
467
+ // runtime keeps one copy of each record's bytes per world, shared by every
468
+ // handle of it, and a field read or write is a DataView access at the
469
+ // field's offset, no JSON. The copy is fetched again (one engine call
470
+ // answering the bytes) only when an engine call or a dispatch happened since
471
+ // it was read, the only times the engine can change a resource; writes mark
472
+ // it dirty and are flushed in one call before the next engine call, at the
473
+ // end of the system or command that made them, and at once outside game
474
+ // code. So two handles agree, a read after an engine call sees the engine's
475
+ // value, and the engine sees every write before it can read the resource:
476
+ // the world (and its hash) is what writing field by field gave.
477
+
478
+ /** A field of a resource's packed layout, as the engine answers it. */
479
+ interface RecordField {
480
+ readonly name: string;
481
+ readonly kind: string;
482
+ readonly offset: number;
483
+ readonly size: number;
484
+ readonly readonly: boolean;
485
+ readonly variants?: readonly string[];
486
+ }
487
+
488
+ interface RecordLayout {
489
+ readonly epoch: number;
490
+ readonly size: number;
491
+ readonly fields: readonly RecordField[];
492
+ }
493
+
494
+ /** One world's copy of one resource's bytes. */
495
+ interface RecordCopy {
496
+ readonly world: number;
497
+ readonly name: string;
498
+ layout: RecordLayout;
499
+ /** The packed record then the layout epoch (u32), as the engine answered and is written back. */
500
+ bytes: Uint8Array;
501
+ view: DataView;
502
+ /** `engineCalls` when the bytes were the engine's; -1: never. */
503
+ at: number;
504
+ dirty: boolean;
505
+ handle: object | null;
506
+ }
507
+
508
+ /** Per world, per resource: its copy, or null for one read as JSON. */
509
+ const records = new Map<number, Map<string, RecordCopy | null>>();
510
+ const dirtyRecords: RecordCopy[] = [];
511
+ /** Calls into game code in progress (systems, commands, facts, start): writes wait for their end. */
512
+ let inGame = 0;
513
+
514
+ const utf8in = new TextEncoder();
515
+ const utf8out = new TextDecoder();
516
+
517
+ function forgetRecords(world: number): void {
518
+ records.delete(world);
519
+ for (let i = dirtyRecords.length - 1; i >= 0; i--) if (dirtyRecords[i]!.world === world) dirtyRecords.splice(i, 1);
520
+ }
521
+
522
+ function writeRecord(r: RecordCopy): void {
523
+ r.dirty = false;
524
+ const answer = parse<Result<void>>(host!.abi.og_engine_write_resource_bytes(r.world, r.name, r.bytes));
525
+ engineCalls++;
526
+ if (!answer.ok) {
527
+ r.at = -1;
528
+ throw new Error(`${r.name}: ${answer.reason}`);
529
+ }
530
+ r.at = engineCalls;
531
+ }
532
+
533
+ /** Writes every dirty record back, oldest first; throws the first refusal after writing the rest. */
534
+ function flushRecords(): void {
535
+ let first: unknown = null;
536
+ for (const r of dirtyRecords.splice(0)) {
537
+ try {
538
+ writeRecord(r);
539
+ } catch (e) {
540
+ first ??= e;
541
+ }
542
+ }
543
+ if (first !== null) throw first;
544
+ }
545
+
546
+ function recordLayout(world: number, name: string): RecordLayout | null {
547
+ const answer = parse<Result<RecordLayout>>(host!.abi.og_engine_resource_layout(world, name));
548
+ return answer.ok ? answer.value : null;
549
+ }
550
+
551
+ /** The world's copy of a resource with a byte layout, or null for one read as JSON (og.Time, og.Rng, og.Input). */
552
+ function recordOf(world: number, name: string): RecordCopy | null {
553
+ let byName = records.get(world);
554
+ if (!byName) records.set(world, (byName = new Map()));
555
+ const known = byName.get(name);
556
+ if (known !== undefined) return known;
557
+ const layout = recordLayout(world, name);
558
+ // An unregistered name is asked again next time (a reload may declare it).
559
+ if (!layout) {
560
+ if (name === "og.Time" || name === "og.Rng" || name === "og.Input") byName.set(name, null);
561
+ return null;
562
+ }
563
+ const bytes = new Uint8Array(layout.size + 4);
564
+ const r: RecordCopy = { world, name, layout, bytes, view: new DataView(bytes.buffer), at: -1, dirty: false, handle: null };
565
+ byName.set(name, r);
566
+ return r;
567
+ }
568
+
569
+ /** Brings the copy up to the engine's value when an engine call happened since; false when the engine refused. */
570
+ function refresh(r: RecordCopy): boolean {
571
+ if (r.dirty || r.at === engineCalls) return true;
572
+ const fetched = host!.abi.og_engine_read_resource(r.world, r.name);
573
+ if (fetched.length === 0) return false;
574
+ const epoch = new DataView(fetched.buffer, fetched.byteOffset, fetched.byteLength).getUint32(fetched.length - 4, true);
575
+ if (epoch !== r.layout.epoch || fetched.length !== r.layout.size + 4) {
576
+ // A reload or a new engine declared the layouts again.
577
+ const layout = recordLayout(r.world, r.name);
578
+ if (!layout || fetched.length !== layout.size + 4) return false;
579
+ r.layout = layout;
580
+ // The next world.resource hands out a handle with the new fields.
581
+ r.handle = null;
582
+ }
583
+ // Into the copy's own buffer: no allocation from one read to the next, and
584
+ // what is written back is memory the runtime made (a host's external
585
+ // buffer crosses back slower).
586
+ if (r.bytes.length !== fetched.length) {
587
+ r.bytes = new Uint8Array(fetched.length);
588
+ r.view = new DataView(r.bytes.buffer);
589
+ }
590
+ r.bytes.set(fetched);
591
+ r.at = engineCalls;
592
+ return true;
593
+ }
594
+
595
+ const LANES: Readonly<Record<string, readonly string[]>> = {
596
+ Vector2: ["x", "y"],
597
+ Vector3: ["x", "y", "z"],
598
+ Quaternion: ["x", "y", "z", "w"],
599
+ Color: ["r", "g", "b"],
600
+ };
601
+
602
+ function decodeField(v: DataView, bytes: Uint8Array, f: RecordField): unknown {
603
+ const o = f.offset;
604
+ switch (f.kind) {
605
+ case "f32": return v.getFloat32(o, true);
606
+ case "f64": return v.getFloat64(o, true);
607
+ case "u32": return v.getUint32(o, true);
608
+ case "i32": return v.getInt32(o, true);
609
+ case "bool": return bytes[o] !== 0;
610
+ case "handle": return v.getUint32(o, true) + v.getUint32(o + 4, true) * 4294967296;
611
+ case "enum": {
612
+ const i = v.getUint32(o, true);
613
+ return f.variants?.[i] ?? i;
614
+ }
615
+ case "str": {
616
+ const lane = bytes.subarray(o, o + f.size);
617
+ const end = lane.indexOf(0);
618
+ return utf8out.decode(end < 0 ? lane : lane.subarray(0, end));
619
+ }
620
+ case "Matrix4": {
621
+ const elements: number[] = [];
622
+ for (let i = 0; i < 16; i++) elements.push(v.getFloat32(o + i * 4, true));
623
+ return { elements };
624
+ }
625
+ default: {
626
+ const out: Record<string, number> = {};
627
+ (LANES[f.kind] ?? []).forEach((k, i) => (out[k] = v.getFloat32(o + i * 4, true)));
628
+ return out;
629
+ }
630
+ }
631
+ }
632
+
633
+ function number(value: unknown, what: string): number {
634
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${what} is a number, got ${JSON.stringify(value) ?? String(value)}`);
635
+ return value;
636
+ }
637
+
638
+ /** Writes `value` into a field's bytes, refusing (throwing) what the engine's JSON path refuses. */
639
+ function encodeField(v: DataView, bytes: Uint8Array, f: RecordField, value: unknown): void {
640
+ const o = f.offset;
641
+ switch (f.kind) {
642
+ case "f32": return v.setFloat32(o, number(value, "the value"), true);
643
+ case "f64": return v.setFloat64(o, number(value, "the value"), true);
644
+ case "u32": {
645
+ const n = number(value, "the value");
646
+ if (!Number.isInteger(n) || n < 0 || n > 4294967295) throw new Error(`${n} is not a u32`);
647
+ return v.setUint32(o, n, true);
648
+ }
649
+ case "i32": {
650
+ const n = number(value, "the value");
651
+ if (!Number.isInteger(n) || n < -2147483648 || n > 2147483647) throw new Error(`${n} is not an i32`);
652
+ return v.setInt32(o, n, true);
653
+ }
654
+ case "bool": {
655
+ if (value !== null && value !== undefined && typeof value !== "boolean") throw new Error(`expected true or false, got ${JSON.stringify(value)}`);
656
+ bytes[o] = value === true ? 1 : 0;
657
+ return;
658
+ }
659
+ case "handle": {
660
+ const n = number(value, "a handle");
661
+ if (!Number.isInteger(n) || n < 0 || n >= 9007199254740992) throw new Error(`${n} is not a handle`);
662
+ v.setUint32(o, n % 4294967296, true);
663
+ v.setUint32(o + 4, Math.floor(n / 4294967296), true);
664
+ return;
665
+ }
666
+ case "enum": {
667
+ const variants = f.variants ?? [];
668
+ let i: number;
669
+ if (value === null || value === undefined) i = 0;
670
+ else if (typeof value === "string") {
671
+ i = variants.indexOf(value);
672
+ if (i < 0) throw new Error(`'${value}' is not one of ${JSON.stringify(variants)}`);
673
+ } else {
674
+ i = number(value, "an enum index");
675
+ if (!Number.isInteger(i) || i < 0 || i >= variants.length) throw new Error(`enum index ${i}, but there are ${variants.length} variants`);
676
+ }
677
+ return v.setUint32(o, i, true);
678
+ }
679
+ case "str": {
680
+ if (value !== null && value !== undefined && typeof value !== "string") throw new Error(`expected a string, got ${JSON.stringify(value)}`);
681
+ const text = (value ?? "") as string;
682
+ const encoded = utf8in.encode(text);
683
+ if (encoded.length > f.size) {
684
+ const shown = [...text].slice(0, 24).join("");
685
+ const more = shown.length < text.length ? "..." : "";
686
+ const fix = encoded.length <= 4096
687
+ ? `shorten the text, or declare the field str(${encoded.length}) or larger`
688
+ : "shorten the text: a string field holds at most 4096 bytes";
689
+ throw new Error(`'${shown}${more}' is ${encoded.length} bytes of UTF-8 and the field holds ${f.size}: ${fix}`);
690
+ }
691
+ if (encoded.includes(0)) throw new Error("a string field cannot hold a NUL character (\\0)");
692
+ bytes.fill(0, o, o + f.size);
693
+ bytes.set(encoded, o);
694
+ return;
695
+ }
696
+ case "Matrix4": {
697
+ const elements = (value as { elements?: unknown } | null)?.elements;
698
+ if (!Array.isArray(elements) || elements.length !== 16) throw new Error(`expected { elements: [16 numbers] }, got ${JSON.stringify(value)}`);
699
+ const lanes = elements.map((e) => number(e, "an element"));
700
+ lanes.forEach((n, i) => v.setFloat32(o + i * 4, n, true));
701
+ return;
702
+ }
703
+ default: {
704
+ const keys = LANES[f.kind] ?? [];
705
+ if (typeof value !== "object" || value === null) throw new Error(`expected { ${keys.join(", ")} }, got ${JSON.stringify(value)}`);
706
+ const lanes = keys.map((k) => number((value as Record<string, unknown>)[k] ?? null, k));
707
+ lanes.forEach((n, i) => v.setFloat32(o + i * 4, n, true));
708
+ }
709
+ }
710
+ }
711
+
712
+ /** The handle of a resource read through its packed bytes, shared by every `world.resource` of it. */
713
+ function recordHandle(world: WorldImpl, r: RecordCopy): object {
714
+ if (r.handle) return r.handle;
715
+ const id = world.id;
716
+ const name = r.name;
717
+ const out: Record<string, unknown> = {};
718
+ const fields = r.layout.fields;
719
+ for (let i = 0; i < fields.length; i++) {
720
+ const key = fields[i]!.name;
721
+ // The field by name in the copy's current layout (a reload may move it).
722
+ const field = (): RecordField => {
723
+ const f = r.layout.fields[i];
724
+ return f && f.name === key ? f : (r.layout.fields.find((x) => x.name === key) ?? fields[i]!);
725
+ };
726
+ let lastValue: unknown;
727
+ const get = (): unknown => {
728
+ if (r.at !== engineCalls && !r.dirty) {
729
+ // Undeclared in a publish build: the engine refuses and logs it once;
730
+ // the handle answers what it last read and the game keeps running.
731
+ if (!checkAccess("read", name)) return lastValue;
732
+ if (!refresh(r)) {
733
+ const answer = parse<Result<Record<string, unknown>>>(host!.abi.og_world_resource(id, name));
734
+ if (!answer.ok) throw new Error(`${name}: ${answer.reason}`);
735
+ return (lastValue = answer.value[key]);
736
+ }
737
+ }
738
+ return (lastValue = decodeField(r.view, r.bytes, field()));
739
+ };
740
+ const set = (value: unknown): void => {
741
+ // Undeclared in a publish build: the engine refuses and logs it once; the game keeps running.
742
+ if (!checkAccess("write", name)) return void abi().og_engine_write_resource(id, name, JSON.stringify({ [key]: value }));
743
+ if (!refresh(r)) {
744
+ const answer = parse<Result<void>>(abi().og_engine_write_resource(id, name, JSON.stringify({ [key]: value })));
745
+ if (!answer.ok) throw new Error(`${name}.${key}: ${answer.reason}`);
746
+ return;
747
+ }
748
+ try {
749
+ // Every check comes before the first byte is written: a refused value leaves the copy as it was.
750
+ encodeField(r.view, r.bytes, field(), value);
751
+ } catch (e) {
752
+ throw new Error(`${name}.${key}: ${(e as Error).message}`);
753
+ }
754
+ if (inGame === 0) return writeRecord(r);
755
+ if (!r.dirty) {
756
+ r.dirty = true;
757
+ dirtyRecords.push(r);
758
+ }
759
+ };
760
+ Object.defineProperty(out, key, fields[i]!.readonly ? { enumerable: true, get } : { enumerable: true, get, set });
761
+ }
762
+ methods(id, name, out);
763
+ return (r.handle = Object.seal(out));
764
+ }
765
+
766
+ /** The methods a built-in resource's handle carries beside its fields. */
767
+ function methods(id: number, name: string, out: Record<string, unknown>): void {
768
+ if (name === "og.Input") {
769
+ Object.assign(out, {
770
+ down: (key: string) => abi().og_input_down(id, key),
771
+ pressed: (key: string) => abi().og_input_pressed(id, key),
772
+ released: (key: string) => abi().og_input_released(id, key),
773
+ axis: (negative: string, positive: string) => abi().og_input_axis(id, negative, positive),
774
+ axis2: (left: string, right: string, down: string, up: string) => parse(abi().og_input_axis2(id, left, right, down, up)),
775
+ buttonDown: (button: string) => abi().og_input_button_down(id, button),
776
+ buttonPressed: (button: string) => abi().og_input_button_pressed(id, button),
777
+ buttonReleased: (button: string) => abi().og_input_button_released(id, button),
778
+ });
779
+ }
780
+ if (name === "og.Pointers") {
781
+ Object.assign(out, {
782
+ down: (pointer: number) => abi().og_pointers_down(id, pointer),
783
+ pressed: (pointer: number) => abi().og_pointers_pressed(id, pointer),
784
+ released: (pointer: number) => abi().og_pointers_released(id, pointer),
785
+ position: (pointer: number) => parse(abi().og_pointers_position(id, pointer)),
786
+ start: (pointer: number) => parse(abi().og_pointers_start(id, pointer)),
787
+ count: () => abi().og_pointers_count(id),
788
+ });
789
+ }
790
+ if (name === "og.Gamepad") {
791
+ Object.assign(out, {
792
+ connected: (pad?: number) => abi().og_gamepad_connected(id, pad),
793
+ down: (button: string, pad?: number) => abi().og_gamepad_down(id, button, pad),
794
+ pressed: (button: string, pad?: number) => abi().og_gamepad_pressed(id, button, pad),
795
+ released: (button: string, pad?: number) => abi().og_gamepad_released(id, button, pad),
796
+ axis: (axis: string, pad?: number) => abi().og_gamepad_axis(id, axis, pad),
797
+ });
798
+ }
799
+ if (name === "og.Camera") {
800
+ Object.assign(out, {
801
+ lookAt: (target: unknown) => abi().og_camera_look_at(id, JSON.stringify(target)),
802
+ worldToScreen: (point: unknown) => parse(abi().og_camera_world_to_screen(id, JSON.stringify(point))),
803
+ screenToRay: (x: number, y: number) => parse(abi().og_camera_screen_to_ray(id, x, y)),
804
+ });
805
+ }
806
+ }
807
+
808
+ /** A resource read as JSON (og.Time, og.Rng, og.Input): computed by the engine, read only. */
809
+ function jsonResource(world: WorldImpl, token: Token, value: Record<string, unknown>): object {
810
+ const id = world.id;
811
+ if (token.name === "og.Rng") {
812
+ return Object.freeze({
813
+ get seed() { return parse<{ ok: boolean; value?: { seed: number } }>(abi().og_world_resource(id, "og.Rng")).value?.seed ?? 0; },
814
+ get state() { return parse<{ ok: boolean; value?: { state: number } }>(abi().og_world_resource(id, "og.Rng")).value?.state ?? 0; },
815
+ // A draw moves the stream: it needs og.Rng among the system's writes. A fork reads the seed only.
816
+ next: () => (checkAccess("write", "og.Rng"), abi().og_rng_next(id)),
817
+ range: (low: number, high: number) => (checkAccess("write", "og.Rng"), abi().og_rng_range(id, low, high)),
818
+ integer: (low: number, high: number) => (checkAccess("write", "og.Rng"), abi().og_rng_integer(id, low, high)),
819
+ chance: (p: number) => (checkAccess("write", "og.Rng"), abi().og_rng_chance(id, p)),
820
+ fork: (label: string) => (checkAccess("read", "og.Rng"), rngStream(abi().og_rng_fork(id, label))),
821
+ });
822
+ }
823
+ // Every getter reads through to the store, kept while no other engine
824
+ // call or dispatch happened since (`engineCalls`).
825
+ const name = token.name;
826
+ let last = value;
827
+ let at = engineCalls;
828
+ const read = (): Record<string, unknown> => {
829
+ if (at === engineCalls) return last;
830
+ if (name !== "og.Time" && !checkAccess("read", name)) return last;
831
+ const answer = parse<Result<Record<string, unknown>>>(host!.abi.og_world_resource(id, name));
832
+ if (answer.ok) {
833
+ last = answer.value;
834
+ at = engineCalls;
835
+ }
836
+ return last;
837
+ };
838
+ const out: Record<string, unknown> = {};
839
+ for (const key of Object.keys(value)) Object.defineProperty(out, key, { enumerable: true, get: () => read()[key] });
840
+ methods(id, name, out);
841
+ return Object.seal(out);
842
+ }
843
+
844
+ function rngStream(handle: number): object {
845
+ return Object.freeze({
846
+ get seed() { return abi().og_rng_stream_get_seed(handle); },
847
+ next: () => abi().og_rng_stream_next(handle),
848
+ range: (low: number, high: number) => abi().og_rng_stream_range(handle, low, high),
849
+ integer: (low: number, high: number) => abi().og_rng_stream_integer(handle, low, high),
850
+ chance: (p: number) => abi().og_rng_stream_chance(handle, p),
851
+ fork: (label: string) => rngStream(abi().og_rng_stream_fork(handle, label)),
852
+ });
853
+ }
854
+
855
+ // ── the world ─────────────────────────────────────────────────────────────
856
+
857
+ /** A structural change outside systems: every view handed out so far goes stale. */
858
+ function structural<T>(answer: T): T {
859
+ if (!running) epoch++;
860
+ return answer;
861
+ }
862
+
863
+ export class WorldImpl {
864
+ readonly id: number;
865
+
866
+ constructor(id: number) {
867
+ this.id = id;
868
+ }
869
+
870
+ get tick(): number {
871
+ return abi().og_world_get_tick(this.id);
872
+ }
873
+
874
+ spawn(desc?: unknown): Result<number> {
875
+ return structural(parse(abi().og_world_spawn(this.id, json(desc))));
876
+ }
877
+
878
+ despawn(entity: number): Result<void> {
879
+ return structural(parse(abi().og_world_despawn(this.id, entity)));
880
+ }
881
+
882
+ alive(entity: number): boolean {
883
+ return abi().og_world_alive(this.id, entity);
884
+ }
885
+
886
+ has(entity: number, component: Token): boolean {
887
+ return abi().og_world_has(this.id, entity, component.name);
888
+ }
889
+
890
+ insert(entity: number, component: Token, value?: unknown): Result<void> {
891
+ return structural(parse(abi().og_world_insert(this.id, entity, component.name, json(value))));
892
+ }
893
+
894
+ remove(entity: number, component: Token): Result<void> {
895
+ return structural(parse(abi().og_world_remove(this.id, entity, component.name)));
896
+ }
897
+
898
+ get(entity: number, component: Token): Result<unknown> {
899
+ return parse(abi().og_world_get(this.id, entity, component.name));
900
+ }
901
+
902
+ set(entity: number, component: Token, value: unknown): Result<void> {
903
+ return structural(parse(abi().og_world_set(this.id, entity, component.name, JSON.stringify(value ?? {}))));
904
+ }
905
+
906
+ resource(resource: Token): Result<object> {
907
+ if (resource.name !== "og.Time") checkAccess("read", resource.name);
908
+ // A resource with a byte layout: its handle, made once per world. The
909
+ // read here is what refuses an unregistered or undeclared resource.
910
+ const r = host ? recordOf(this.id, resource.name) : null;
911
+ if (r && (r.dirty || r.at === engineCalls || refresh(r))) return { ok: true, value: recordHandle(this, r) };
912
+ const answer = parse<Result<Record<string, unknown>>>(abi().og_world_resource(this.id, resource.name));
913
+ if (!answer.ok) return answer;
914
+ return { ok: true, value: r ? recordHandle(this, r) : jsonResource(this, resource, answer.value) };
915
+ }
916
+
917
+ find(name: string): number | null {
918
+ return parse(abi().og_world_find(this.id, name));
919
+ }
920
+
921
+ snapshot(): Result<string> {
922
+ return parse(abi().og_world_snapshot(this.id));
923
+ }
924
+
925
+ restore(save: string, desc?: unknown): Result<void> {
926
+ return structural(parse(abi().og_world_restore(this.id, save, json(desc))));
927
+ }
928
+
929
+ nameOf(entity: number): string | null {
930
+ return parse(abi().og_world_name_of(this.id, entity));
931
+ }
932
+
933
+ query(desc?: { with?: readonly Token[]; without?: readonly Token[] }): Result<QueryImpl> {
934
+ const wire = desc && { with: (desc.with ?? []).map((t) => t.name), without: (desc.without ?? []).map((t) => t.name) };
935
+ const answer = parse<Result<number>>(abi().og_world_query(this.id, json(wire)));
936
+ if (!answer.ok) return answer;
937
+ return { ok: true, value: new QueryImpl(answer.value, null) };
938
+ }
939
+
940
+ events(event: Token): readonly unknown[] {
941
+ checkAccess("read", event.name);
942
+ return parse(abi().og_world_events(this.id, event.name));
943
+ }
944
+
945
+ emit(event: Token, payload: unknown): Result<void> {
946
+ checkAccess("write", event.name);
947
+ return parse(abi().og_world_emit(this.id, event.name, JSON.stringify(payload ?? {})));
948
+ }
949
+
950
+ instantiate(asset: number, desc?: unknown): Result<number> {
951
+ return structural(parse(abi().og_world_instantiate(this.id, asset, json(desc))));
952
+ }
953
+ }
954
+
955
+ const worlds = new Map<number, WorldImpl>();
956
+
957
+ function worldFor(id: number): WorldImpl {
958
+ let w = worlds.get(id);
959
+ if (!w) worlds.set(id, (w = new WorldImpl(id)));
960
+ return w;
961
+ }
962
+
963
+ const rates = new Map<number, number>();
964
+
965
+ // ── the dispatcher ────────────────────────────────────────────────────────
966
+
967
+ function message(e: unknown): string {
968
+ const text = e instanceof Error ? (e.stack ?? e.message) : String(e);
969
+ if (text.length === 0) return "game code threw an empty error";
970
+ return text.startsWith("=") ? ` ${text}` : text;
971
+ }
972
+
973
+ /** What the engine calls: kind 0 a system, 1 a command, 2 the facts function. */
974
+ const dispatch: Dispatcher = (kind, engine, id, query, tick, dt, elapsed, payload) => {
975
+ const fn = callbacks[id];
976
+ const world = worldFor(engine);
977
+ const outer = running;
978
+ engineCalls++;
979
+ inGame++;
980
+ try {
981
+ if (kind === 0) {
982
+ const system = systems.get(id);
983
+ if (!system || !fn) return `the dispatcher has no system with callback id ${id}: the bundle declared ${systems.size} system(s); was the engine built from another bundle?`;
984
+ running = { system, tick };
985
+ // The payload carries the time scale: scale,hitStop,scaledDt,scaledElapsed.
986
+ const ts = (payload || "1,0,0,0").split(",").map(Number);
987
+ const time = Object.freeze({
988
+ tick, dt, elapsed, rate: rates.get(engine) ?? Math.round(1 / dt),
989
+ scale: ts[0] ?? 1, hitStop: ts[1] ?? 0, scaledDt: ts[2] ?? dt, scaledElapsed: ts[3] ?? elapsed,
990
+ });
991
+ fn(new QueryImpl(query, system), time, world);
992
+ flushRecords();
993
+ return "";
994
+ }
995
+ if (!fn) return `the dispatcher has no callback with id ${id}`;
996
+ if (kind === 1) {
997
+ fn(world, Object.freeze(JSON.parse(payload || "[]")));
998
+ flushRecords();
999
+ return "";
1000
+ }
1001
+ if (kind === 2) {
1002
+ const facts = JSON.stringify(fn(world) ?? null);
1003
+ flushRecords();
1004
+ return "=" + facts;
1005
+ }
1006
+ return `unknown dispatch kind ${kind}`;
1007
+ } catch (e) {
1008
+ return message(e);
1009
+ } finally {
1010
+ // What game code wrote before it threw is written too, as a write
1011
+ // straight through would have been.
1012
+ if (dirtyRecords.length > 0) {
1013
+ try {
1014
+ flushRecords();
1015
+ } catch {
1016
+ // The throw already failed the call; this refusal adds nothing.
1017
+ }
1018
+ }
1019
+ inGame--;
1020
+ running = outer;
1021
+ epoch++;
1022
+ engineCalls++;
1023
+ }
1024
+ };
1025
+
1026
+ // ── engines ───────────────────────────────────────────────────────────────
1027
+
1028
+ export interface CreateOptions {
1029
+ /** Overrides GameDesc.seed (a replay seed). */
1030
+ readonly seed?: number;
1031
+ /** The project directory (native) or URL base (web) whose `cooked/` bundle the engine opens before the first tick. */
1032
+ readonly root?: string;
1033
+ /** The physics profile, project.ogt's `profile`: `3d` (the default) or `2d`. */
1034
+ readonly profile?: "2d" | "3d";
1035
+ /**
1036
+ * project.ogt's text (`og run`, `og test`, `og dev`, `og polish` and `og
1037
+ * build` pass it): its `tick`, `seed`, `scenes` and `scene` fill the
1038
+ * GameDesc fields the game leaves unset, and one set in both places to
1039
+ * different values refuses the engine.
1040
+ */
1041
+ readonly project?: string;
1042
+ /** Run GameDesc.start at the first tick boundary instead of now, so a host can attach its renderer and report the viewport first (the web Worker does). */
1043
+ readonly deferStart?: boolean;
1044
+ /** The locale the host asks og.i18n for: the browser's language, `og run --locale`. */
1045
+ readonly locale?: string;
1046
+ /** The host mirrors the UI's accessibility tree (the web Worker): a frame's answer carries `a11y` when the tree changed. */
1047
+ readonly a11y?: boolean;
1048
+ }
1049
+
1050
+ /** A file the command channel produced (`og_engine_take_outputs`). */
1051
+ export interface CommandOutput {
1052
+ readonly tick: number;
1053
+ readonly action: string;
1054
+ /** Where to write it: the script's path, or `<action>-<tick>.<ext>`. */
1055
+ readonly file: string;
1056
+ readonly kind: "png" | "json" | "text" | "wav";
1057
+ /** Base64 for a PNG, the text otherwise. */
1058
+ readonly data: string;
1059
+ }
1060
+
1061
+ /** One log line (`og_engine_take_log`). */
1062
+ export interface LogLine {
1063
+ readonly tick: number;
1064
+ readonly warning: boolean;
1065
+ readonly text: string;
1066
+ }
1067
+
1068
+ /** Start a new bundle's declarations before importing it again (hot reload). */
1069
+ export function beginBundle(): void {
1070
+ abi().og_host_begin_bundle();
1071
+ }
1072
+
1073
+ /**
1074
+ * The host's wall clock as og.platform hands it to the game (og.platform.now,
1075
+ * PlatformResult.time): read only between ticks, when the platform attaches
1076
+ * and when an answer arrives, so it reaches the world as host input.
1077
+ */
1078
+ export interface HostClock {
1079
+ /** PlatformInfo.clock: `system`, `browser`, `fixed` or `player`. */
1080
+ readonly kind: string;
1081
+ /** UTC milliseconds since 1970. */
1082
+ now(): number;
1083
+ }
1084
+
1085
+ /** The machine's clock: the native host's (`system`), the web host's (`browser`). */
1086
+ export const systemClock = (kind = "system"): HostClock => ({ kind, now: () => Date.now() });
1087
+
1088
+ /** A clock that starts at `startMs` on tick 0 and moves with the engine's ticks (`og run --clock`, playtests): reproducible. */
1089
+ export function fixedClock(engine: Engine, startMs: number): HostClock {
1090
+ return { kind: "fixed", now: () => startMs + (engine.world.tick * 1000) / (rates.get(engine.id) ?? 60) };
1091
+ }
1092
+
1093
+ /** A running engine: one world, its probe, and the host's lifecycle calls. */
1094
+ export class Engine {
1095
+ readonly id: number;
1096
+ readonly game: GameToken;
1097
+ /** The engine's world, as game code sees it. */
1098
+ readonly world: Api.World;
1099
+ /** The engine's probe: RuntimeProbe protocol 5 and the wire members. */
1100
+ readonly probe: Api.EngineProbe;
1101
+
1102
+ #platform: PlatformHost = noPlatform;
1103
+ #clock: HostClock = systemClock();
1104
+
1105
+ constructor(id: number, game: GameToken) {
1106
+ this.id = id;
1107
+ this.game = game;
1108
+ this.world = worldFor(id) as unknown as Api.World;
1109
+ this.probe = makeProbe(id) as unknown as Api.EngineProbe;
1110
+ // The rate the engine runs at: GameDesc.tick, else project.ogt's, else 60.
1111
+ const time = parse<Result<{ rate?: number }>>(abi().og_world_resource(id, "og.Time"));
1112
+ rates.set(id, (time.ok ? time.value.rate : undefined) ?? game.tick);
1113
+ learnBuild(id);
1114
+ this.attachPlatform(noPlatform);
1115
+ }
1116
+
1117
+ /** The fixed tick rate the engine runs at, in hertz (GameDesc.tick, else project.ogt's `tick`, else 60). */
1118
+ get rate(): number {
1119
+ return rates.get(this.id) ?? this.game.tick;
1120
+ }
1121
+
1122
+ /** Attach what answers og.platform's calls: the native host's local store, the web shell's Player bridge. */
1123
+ attachPlatform(host: PlatformHost): void {
1124
+ this.#platform = host;
1125
+ abi().og_platform_set_host(this.id, JSON.stringify({ host: host.host, capabilities: host.capabilities, time: this.#clock.now(), clock: this.#clock.kind }));
1126
+ }
1127
+
1128
+ /** Set the clock og.platform tells the game (before attachPlatform, which tells it the time). */
1129
+ setClock(clock: HostClock): void {
1130
+ this.#clock = clock;
1131
+ abi().og_platform_set_host(this.id, JSON.stringify({ host: this.#platform.host, capabilities: this.#platform.capabilities, time: clock.now(), clock: clock.kind }));
1132
+ }
1133
+
1134
+ /** Hand the platform calls made since the last pump to the attached host (before and after every advance and frame, after a command). */
1135
+ pumpPlatform(): void {
1136
+ const calls = parse<PlatformCall[]>(abi().og_platform_take(this.id));
1137
+ if (calls.length > 0) this.#platform.handle(calls, (request, answer) => void this.answerPlatform(request, answer));
1138
+ }
1139
+
1140
+ /** og.platform's calls the world has no answer to yet: in flight at the host, or answered and applied at the next tick's start. */
1141
+ platformPending(): number {
1142
+ return (parse<{ pending?: number }>(abi().og_platform_info(this.id)).pending ?? 0) as number;
1143
+ }
1144
+
1145
+ /** Answer one platform call; applied at the start of the next tick. */
1146
+ answerPlatform(request: number, answer: PlatformAnswer): boolean {
1147
+ // An answer that carries no time (a Player before the time ask, the local platform) gets the host clock's.
1148
+ const timed = typeof answer.time === "number" ? { ...answer, clock: "player" } : { ...answer, time: this.#clock.now() };
1149
+ return abi().og_platform_answer(this.id, request, JSON.stringify(timed));
1150
+ }
1151
+
1152
+ /** Take fixed steps: the tick reached, or the first failing system with its tick. */
1153
+ advance(steps: number): Result<number> {
1154
+ // Calls made between ticks are answered before the first of these ticks applies answers.
1155
+ this.pumpPlatform();
1156
+ const answer = parse<Result<number>>(abi().og_engine_advance(this.id, steps));
1157
+ epoch++;
1158
+ this.pumpPlatform();
1159
+ return answer;
1160
+ }
1161
+
1162
+ /** Run a registered command action now, between ticks. */
1163
+ command(action: string, args: readonly string[] = []): Result<void> {
1164
+ const answer = parse<Result<void>>(abi().og_engine_command(this.id, action, JSON.stringify(args)));
1165
+ epoch++;
1166
+ this.pumpPlatform();
1167
+ return answer;
1168
+ }
1169
+
1170
+ /** One display frame: tick by the wall clock (through the probe: due command steps run), then render. */
1171
+ frame(elapsedMs: number): {
1172
+ ok: boolean;
1173
+ ticks?: number;
1174
+ tick?: number;
1175
+ rendered?: boolean;
1176
+ paused?: boolean;
1177
+ quit?: boolean;
1178
+ behind?: number;
1179
+ /** Wall milliseconds a tick took (this frame's mean, or the last frame's that ticked) and the last render took. */
1180
+ stepMs?: number;
1181
+ renderMs?: number;
1182
+ reason?: string;
1183
+ /** The UI's focused text control (web/text-input.ts's TextTarget), or null. */
1184
+ text?: { path: string; rect: [number, number, number, number]; multiline: boolean; selection: string; composing: boolean } | null;
1185
+ /** Text a copy or cut took this frame. */
1186
+ copy?: string;
1187
+ /** The UI's accessibility tree, on the frames where it changed, when the engine was created with `a11y` (web/a11y.ts mirrors it). */
1188
+ a11y?: Api.A11yTree;
1189
+ } {
1190
+ this.pumpPlatform();
1191
+ const answer = parse<{ ok: boolean; ticks?: number }>(abi().og_engine_frame(this.id, elapsedMs));
1192
+ epoch++;
1193
+ this.pumpPlatform();
1194
+ return answer;
1195
+ }
1196
+
1197
+ /** The probe's JSON-RPC 2.0 entry: one request frame in, its answer frame out. */
1198
+ call(frame: string): string {
1199
+ const answer = abi().og_engine_call(this.id, frame);
1200
+ epoch++;
1201
+ return answer;
1202
+ }
1203
+
1204
+ /** Files the command channel produced since the last call. */
1205
+ takeOutputs(): CommandOutput[] {
1206
+ return parse(abi().og_engine_take_outputs(this.id));
1207
+ }
1208
+
1209
+ /** Log lines since the last call. */
1210
+ takeLog(): LogLine[] {
1211
+ return parse(abi().og_engine_take_log(this.id));
1212
+ }
1213
+
1214
+ /** The world hash, hex: equal hashes mean equal worlds. */
1215
+ hash(): string {
1216
+ const dump = parse<{ hash?: string } | null>(abi().og_engine_probe_dump(this.id, JSON.stringify({ what: "world" })));
1217
+ return dump?.hash ?? "";
1218
+ }
1219
+
1220
+ destroy(): boolean {
1221
+ epoch++;
1222
+ worlds.delete(this.id);
1223
+ forgetRecords(this.id);
1224
+ return abi().og_engine_destroy(this.id);
1225
+ }
1226
+ }
1227
+
1228
+ /** Assemble an engine from a declared game (the bundle's default export). */
1229
+ export function createEngine(game: GameToken, options: CreateOptions = {}): Result<Engine> {
1230
+ const handle = gameHandles.get(game);
1231
+ if (handle === undefined) return refused("not a game: pass the bundle's default export, the result of og.game()");
1232
+ const answer = parse<Result<number>>(abi().og_engine_create(handle, JSON.stringify(options)));
1233
+ if (!answer.ok) return answer;
1234
+ forgetRecords(answer.value);
1235
+ return { ok: true, value: new Engine(answer.value, game) };
1236
+ }
1237
+
1238
+ /** A probe member's parameter as the binding table declares it. */
1239
+ interface ProbeParam {
1240
+ readonly name: string;
1241
+ readonly type: string;
1242
+ readonly optional: boolean;
1243
+ }
1244
+
1245
+ /**
1246
+ * The probe members' string and number parameters, with their positions. A
1247
+ * probe is driven by untyped callers (the engine Worker's `call("probe", …)`,
1248
+ * a console): on the web host a non-string where the ABI takes a string traps
1249
+ * the wasm inside wasm-bindgen's string passing (out-of-bounds memory), and
1250
+ * the native host throws a conversion error, so both hosts refuse it here,
1251
+ * before the host sees it, with the probe's own words ("script must be a
1252
+ * string").
1253
+ */
1254
+ const PROBE_PARAMS = new Map(
1255
+ (bindings.calls as Array<{ module: string; kind: string; name: string; params?: ProbeParam[] }>)
1256
+ .filter((c) => c.module === "probe" && c.kind === "method")
1257
+ .map((c) => [
1258
+ c.name,
1259
+ (c.params ?? [])
1260
+ .map((p, at) => ({ ...p, at, want: p.type === "String" ? "string" : ["u32", "i32", "f32", "f64"].includes(p.type) ? "number" : "" }))
1261
+ .filter((p) => p.want !== ""),
1262
+ ]),
1263
+ );
1264
+
1265
+ function checkedProbe<T extends Record<string, unknown>>(members: T): T {
1266
+ const out: Record<string, unknown> = {};
1267
+ for (const [name, member] of Object.entries(Object.getOwnPropertyDescriptors(members))) {
1268
+ const params = PROBE_PARAMS.get(name) ?? [];
1269
+ const f = member.value as ((...args: unknown[]) => unknown) | undefined;
1270
+ if (params.length === 0 || typeof f !== "function") {
1271
+ Object.defineProperty(out, name, member);
1272
+ continue;
1273
+ }
1274
+ out[name] = (...args: unknown[]) => {
1275
+ for (const p of params) {
1276
+ const v = args[p.at];
1277
+ if (v === undefined && p.optional) continue;
1278
+ if (typeof v !== p.want) throw new TypeError(`probe.${name}: ${p.name} must be a ${p.want}, not ${v === null ? "null" : typeof v}`);
1279
+ }
1280
+ return f(...args);
1281
+ };
1282
+ }
1283
+ return Object.freeze(out) as T;
1284
+ }
1285
+
1286
+ function makeProbe(id: number) {
1287
+ const a = abi;
1288
+ return checkedProbe({
1289
+ get version() { return a().og_runtime_probe_get_version(id); },
1290
+ tick: () => a().og_runtime_probe_tick(id),
1291
+ state: () => parse(a().og_runtime_probe_state(id)),
1292
+ advance: (steps: number) => { const t = a().og_runtime_probe_advance(id, steps); epoch++; return t; },
1293
+ pause: () => a().og_runtime_probe_pause(id),
1294
+ resume: () => a().og_runtime_probe_resume(id),
1295
+ input: (event: unknown) => a().og_runtime_probe_input(id, JSON.stringify(event)),
1296
+ accepted: () => a().og_runtime_probe_accepted(id),
1297
+ pick: (x: number, y: number) => parse(a().og_runtime_probe_pick(id, x, y)),
1298
+ entity: (pid: string) => parse(a().og_runtime_probe_entity(id, pid)),
1299
+ bounds: (pid: string) => parse(a().og_runtime_probe_bounds(id, pid)),
1300
+ patch: (pid: string, parameter: string, value: unknown) => a().og_runtime_probe_patch(id, pid, parameter, JSON.stringify(value)),
1301
+ run: (program: unknown) => { const r = parse(a().og_runtime_probe_run(id, JSON.stringify(program))); epoch++; return r; },
1302
+ hello: () => parse(a().og_engine_probe_hello(id)),
1303
+ capture: (width?: number, height?: number) => parse(a().og_engine_probe_capture(id, width, height)),
1304
+ sweep: (desc?: unknown) => parse(a().og_engine_probe_sweep(id, json(desc))),
1305
+ reload: () => parse(a().og_engine_probe_reload(id)),
1306
+ snapshot: () => parse(a().og_engine_probe_snapshot(id)),
1307
+ restore: (sid: string) => { epoch++; return a().og_engine_probe_restore(id, sid); },
1308
+ dump: (desc?: unknown) => parse(a().og_engine_probe_dump(id, json(desc))),
1309
+ commands: (script: string) => parse(a().og_engine_probe_commands(id, script)),
1310
+ listen: (seconds?: number) => parse(a().og_engine_probe_listen(id, seconds)),
1311
+ memory: () => parse(a().og_engine_probe_memory(id)),
1312
+ });
1313
+ }
1314
+
1315
+ // ── free functions ────────────────────────────────────────────────────────
1316
+
1317
+ export const transform = Object.freeze({
1318
+ compose: (position: unknown, quaternion: unknown, scale: unknown) =>
1319
+ parse(abi().og_transform_compose(JSON.stringify(position), JSON.stringify(quaternion), JSON.stringify(scale))),
1320
+ decompose: (matrix: unknown) => parse(abi().og_transform_decompose(JSON.stringify(matrix))),
1321
+ lookAt: (eye: unknown, target: unknown, up?: unknown) =>
1322
+ parse(abi().og_transform_look_at(JSON.stringify(eye), JSON.stringify(target), json(up))),
1323
+ fromAxisAngle: (axis: unknown, angle: number) => parse(abi().og_transform_from_axis_angle(JSON.stringify(axis), angle)),
1324
+ });
1325
+
1326
+ export const render = Object.freeze({
1327
+ standardMaterial: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_standard_material(world.id, json(desc))),
1328
+ updateMaterial: (world: WorldImpl, material: number, desc?: unknown) =>
1329
+ parse(abi().og_render_update_material(world.id, material, json(desc))),
1330
+ capabilities: (world: WorldImpl) => parse(abi().og_render_capabilities(world.id)),
1331
+ applyLook: (world: WorldImpl, look: number) => parse(abi().og_render_apply_look(world.id, look)),
1332
+ look: (world: WorldImpl) => parse(abi().og_render_look(world.id)),
1333
+ text: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_text(world.id, json(desc))),
1334
+ sprite: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_sprite(world.id, json(desc))),
1335
+ ribbon: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_ribbon(world.id, json(desc))),
1336
+ measureText: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_measure_text(world.id, json(desc))),
1337
+ floatingText: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_floating_text(world.id, json(desc))),
1338
+ boxGeometry: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_box_geometry(world.id, json(desc))),
1339
+ planeGeometry: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_plane_geometry(world.id, json(desc))),
1340
+ sphereGeometry: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_sphere_geometry(world.id, json(desc))),
1341
+ shaderMaterial: (world: WorldImpl, shader: number, desc?: unknown) =>
1342
+ parse(abi().og_render_shader_material(world.id, shader, json(desc))),
1343
+ setMaterialShader: (world: WorldImpl, material: number, shader: number | null) =>
1344
+ parse(abi().og_render_set_material_shader(world.id, material, JSON.stringify(shader ?? null))),
1345
+ setShaderParam: (world: WorldImpl, material: number, name: string, value: ReadonlyArray<number>) =>
1346
+ parse(abi().og_render_set_shader_param(world.id, material, name, JSON.stringify(value))),
1347
+ });
1348
+
1349
+ export const load = Object.freeze({
1350
+ bundle: (world: WorldImpl, path: string) => parse(abi().og_load_bundle(world.id, path)),
1351
+ bundleInfo: (world: WorldImpl, asset: number) => parse(abi().og_load_bundle_info(world.id, asset)),
1352
+ defaultBundle: (world: WorldImpl) => parse(abi().og_load_default_bundle(world.id)),
1353
+ gltf: (world: WorldImpl, path: string, desc?: unknown) => parse(abi().og_load_gltf(world.id, path, json(desc))),
1354
+ scene: (world: WorldImpl, path: string) => parse(abi().og_load_scene(world.id, path)),
1355
+ font: (world: WorldImpl, path: string) => parse(abi().og_load_font(world.id, path)),
1356
+ fontInfo: (world: WorldImpl, font: number) => parse(abi().og_load_font_info(world.id, font)),
1357
+ sound: (world: WorldImpl, path: string) => parse(abi().og_load_sound(world.id, path)),
1358
+ texture: (world: WorldImpl, path: string) => parse(abi().og_load_texture(world.id, path)),
1359
+ textureArray: (world: WorldImpl, path: string) => parse(abi().og_load_texture_array(world.id, path)),
1360
+ lut: (world: WorldImpl, path: string) => parse(abi().og_load_lut(world.id, path)),
1361
+ environment: (world: WorldImpl, path: string) => parse(abi().og_load_environment(world.id, path)),
1362
+ emitter: (world: WorldImpl, path: string) => parse(abi().og_load_emitter(world.id, path)),
1363
+ shader: (world: WorldImpl, path: string) => parse(abi().og_load_shader(world.id, path)),
1364
+ skin: (world: WorldImpl, path: string) => parse(abi().og_load_skin(world.id, path)),
1365
+ look: (world: WorldImpl, path: string) => parse(abi().og_load_look(world.id, path)),
1366
+ lookInfo: (world: WorldImpl, look: number) => parse(abi().og_load_look_info(world.id, look)),
1367
+ palette: (world: WorldImpl, path: string) => parse(abi().og_load_palette(world.id, path)),
1368
+ soundInfo: (world: WorldImpl, sound: number) => parse(abi().og_load_sound_info(world.id, sound)),
1369
+ status: (world: WorldImpl, asset: number) => abi().og_load_status(world.id, asset),
1370
+ error: (world: WorldImpl, asset: number) => parse(abi().og_load_error(world.id, asset)),
1371
+ gltfInfo: (world: WorldImpl, asset: number) => parse(abi().og_load_gltf_info(world.id, asset)),
1372
+ animation: (world: WorldImpl, path: string) => parse(abi().og_load_animation(world.id, path)),
1373
+ flipbook: (world: WorldImpl, path: string) => parse(abi().og_load_flipbook(world.id, path)),
1374
+ rig: (world: WorldImpl, path: string) => parse(abi().og_load_rig(world.id, path)),
1375
+ tilemap: (world: WorldImpl, path: string) => parse(abi().og_load_tilemap(world.id, path)),
1376
+ });
1377
+
1378
+ export const anim = Object.freeze({
1379
+ graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
1380
+ humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
1381
+ retarget: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_retarget(world.id, json(desc))),
1382
+ // One lookup for a flipbook's clips (an index, -1 when missing) and a
1383
+ // loaded model's (og.load.animation: a ClipAsset, 0 when missing).
1384
+ clip: (info: { clips?: ReadonlyArray<{ clip: number; name: string }>; skeleton?: number }, name: string): number =>
1385
+ info?.clips?.find((c) => c.name === name)?.clip ?? (info?.skeleton !== undefined ? 0 : -1),
1386
+ });
1387
+
1388
+ // ── E4: time scale, camera feel, tweens, tilemaps ───────────────────────────
1389
+
1390
+ export const time = Object.freeze({
1391
+ setScale: (world: WorldImpl, scale: number) => parse(abi().og_time_set_scale(world.id, scale)),
1392
+ hitStop: (world: WorldImpl, ticks: number) => parse(abi().og_time_hit_stop(world.id, ticks)),
1393
+ });
1394
+
1395
+ export const camera = Object.freeze({
1396
+ shake: (world: WorldImpl, trauma: number) => parse(abi().og_camera_shake(world.id, trauma)),
1397
+ punch: (world: WorldImpl, amount: number) => parse(abi().og_camera_punch(world.id, amount)),
1398
+ kick: (world: WorldImpl, direction: unknown, distance: number) =>
1399
+ parse(abi().og_camera_kick(world.id, JSON.stringify(direction), distance)),
1400
+ });
1401
+
1402
+ export const tween = Object.freeze({
1403
+ to: (world: WorldImpl, desc?: unknown) => parse(abi().og_tween_to(world.id, json(desc))),
1404
+ sequence: (world: WorldImpl, steps: unknown, desc?: unknown) =>
1405
+ parse(abi().og_tween_sequence(world.id, JSON.stringify(steps), json(desc))),
1406
+ parallel: (world: WorldImpl, tweens: unknown, desc?: unknown) =>
1407
+ parse(abi().og_tween_parallel(world.id, JSON.stringify(tweens), json(desc))),
1408
+ cancel: (world: WorldImpl, t: number) => abi().og_tween_cancel(world.id, t),
1409
+ cancelEntity: (world: WorldImpl, entity: number) => abi().og_tween_cancel_entity(world.id, entity),
1410
+ active: (world: WorldImpl, t: number) => abi().og_tween_active(world.id, t),
1411
+ squash: (world: WorldImpl, desc?: unknown) => parse(abi().og_tween_squash(world.id, json(desc))),
1412
+ flash: (world: WorldImpl, desc?: unknown) => parse(abi().og_tween_flash(world.id, json(desc))),
1413
+ });
1414
+
1415
+ export const tilemap = Object.freeze({
1416
+ setTile: (world: WorldImpl, desc?: unknown) => parse(abi().og_tilemap_set_tile(world.id, json(desc))),
1417
+ getTile: (world: WorldImpl, entity: number, layer: string, x: number, y: number) =>
1418
+ abi().og_tilemap_get_tile(world.id, entity, layer, x, y),
1419
+ cellAt: (world: WorldImpl, entity: number, point: unknown) =>
1420
+ parse(abi().og_tilemap_cell_at(world.id, entity, JSON.stringify(point))),
1421
+ cellCenter: (world: WorldImpl, entity: number, x: number, y: number) =>
1422
+ parse(abi().og_tilemap_cell_center(world.id, entity, x, y)),
1423
+ });
1424
+
1425
+ export const commands = Object.freeze({
1426
+ register: registerCommand,
1427
+ parse: (script: string) => parse(abi().og_commands_parse(script)),
1428
+ schedule: (world: WorldImpl, script: string) => parse(abi().og_commands_schedule(world.id, script)),
1429
+ help: (world: WorldImpl) => parse(abi().og_commands_help(world.id)),
1430
+ });
1431
+
1432
+ export const physics = Object.freeze({
1433
+ raycast: (world: WorldImpl, desc?: unknown) => parse(abi().og_physics_raycast(world.id, json(desc))),
1434
+ shapeCast: (world: WorldImpl, desc?: unknown) => parse(abi().og_physics_shape_cast(world.id, json(desc))),
1435
+ overlap: (world: WorldImpl, desc?: unknown) => parse(abi().og_physics_overlap(world.id, json(desc))),
1436
+ applyForce: (world: WorldImpl, entity: number, force: unknown, point?: unknown) =>
1437
+ parse(abi().og_physics_apply_force(world.id, entity, JSON.stringify(force), json(point))),
1438
+ applyImpulse: (world: WorldImpl, entity: number, impulse: unknown, point?: unknown) =>
1439
+ parse(abi().og_physics_apply_impulse(world.id, entity, JSON.stringify(impulse), json(point))),
1440
+ applyTorque: (world: WorldImpl, entity: number, torque: unknown) =>
1441
+ parse(abi().og_physics_apply_torque(world.id, entity, JSON.stringify(torque))),
1442
+ applyTorqueImpulse: (world: WorldImpl, entity: number, torque: unknown) =>
1443
+ parse(abi().og_physics_apply_torque_impulse(world.id, entity, JSON.stringify(torque))),
1444
+ setMesh: (world: WorldImpl, mesh: number, desc?: unknown) => parse(abi().og_physics_set_mesh(world.id, mesh, json(desc))),
1445
+ ropePoints: (world: WorldImpl, entity: number) => parse(abi().og_physics_rope_points(world.id, entity)),
1446
+ clothPoints: (world: WorldImpl, entity: number) => parse(abi().og_physics_cloth_points(world.id, entity)),
1447
+ fluidParticles: (world: WorldImpl, entity: number) => parse(abi().og_physics_fluid_particles(world.id, entity)),
1448
+ fluid3dParticles: (world: WorldImpl, entity: number) => parse(abi().og_physics_fluid3d_particles(world.id, entity)),
1449
+ waterHeight: (world: WorldImpl, entity: number, x: number, z: number) =>
1450
+ parse(abi().og_physics_water_height(world.id, entity, x, z)),
1451
+ vehicleWheels: (world: WorldImpl, entity: number) => parse(abi().og_physics_vehicle_wheels(world.id, entity)),
1452
+ ragdollRigFromSkeleton: (world: WorldImpl, skeleton: number) =>
1453
+ parse(abi().og_physics_ragdoll_rig_from_skeleton(world.id, skeleton)),
1454
+ setRagdollRig: (world: WorldImpl, entity: number, desc?: unknown) =>
1455
+ parse(abi().og_physics_set_ragdoll_rig(world.id, entity, json(desc))),
1456
+ ragdollPose: (world: WorldImpl, entity: number) => parse(abi().og_physics_ragdoll_pose(world.id, entity)),
1457
+ stats: (world: WorldImpl) => parse(abi().og_physics_stats(world.id)),
1458
+ });
1459
+
1460
+ export const audio = Object.freeze({
1461
+ outputLatency: (world: WorldImpl) => (checkAccess("read", "og.Audio"), abi().og_audio_output_latency(world.id)),
1462
+ });
1463
+
1464
+ export const atmosphere = Object.freeze({
1465
+ timeOfDay: (world: WorldImpl) => abi().og_atmosphere_time_of_day(world.id),
1466
+ });
1467
+
1468
+ /** Runs `build` between an open and its close, closing even when it throws. */
1469
+ function within<T>(open: () => void, build: () => void): T {
1470
+ open();
1471
+ try {
1472
+ build();
1473
+ } catch (e) {
1474
+ abi().og_ui_close();
1475
+ throw e;
1476
+ }
1477
+ return parse<T>(abi().og_ui_close());
1478
+ }
1479
+
1480
+ export const ui = Object.freeze({
1481
+ configure: (world: WorldImpl, desc?: unknown) => parse(abi().og_ui_configure(world.id, json(desc))),
1482
+ viewport: (world: WorldImpl) => parse(abi().og_ui_viewport(world.id)),
1483
+ theme: (world: WorldImpl) => parse(abi().og_ui_theme(world.id)),
1484
+ open: (id: string, desc?: unknown) => abi().og_ui_open(id, json(desc)),
1485
+ openPanel: (id: string, desc?: unknown) => abi().og_ui_open_panel(id, json(desc)),
1486
+ openScroll: (id: string, desc?: unknown) => abi().og_ui_open_scroll(id, json(desc)),
1487
+ close: () => parse(abi().og_ui_close()),
1488
+ row: (id: string, desc: Record<string, unknown> | undefined, build: () => void) =>
1489
+ within(() => abi().og_ui_open(id, JSON.stringify({ ...(desc ?? {}), direction: "row" })), build),
1490
+ column: (id: string, desc: Record<string, unknown> | undefined, build: () => void) =>
1491
+ within(() => abi().og_ui_open(id, JSON.stringify({ ...(desc ?? {}), direction: "column" })), build),
1492
+ panel: (id: string, desc: unknown, build: () => void): void => {
1493
+ within(() => abi().og_ui_open_panel(id, json(desc)), build);
1494
+ },
1495
+ scroll: (id: string, desc: unknown, build: () => void): void => {
1496
+ within(() => abi().og_ui_open_scroll(id, json(desc)), build);
1497
+ },
1498
+ label: (id: string, text: string, desc?: unknown) => abi().og_ui_label(id, text, json(desc)),
1499
+ richText: (id: string, markup: string, desc?: unknown) => parse(abi().og_ui_rich_text(id, markup, json(desc))),
1500
+ button: (id: string, text: string, desc?: unknown) => parse(abi().og_ui_button(id, text, json(desc))),
1501
+ image: (id: string, desc?: unknown) => abi().og_ui_image(id, json(desc)),
1502
+ progress: (id: string, value: number, desc?: unknown) => abi().og_ui_progress(id, value, json(desc)),
1503
+ slider: (id: string, value: number, desc?: unknown) => parse(abi().og_ui_slider(id, value, json(desc))),
1504
+ toggle: (id: string, on: boolean, label: string) => parse(abi().og_ui_toggle(id, on, label)),
1505
+ textField: (id: string, value: string, desc?: unknown) => parse(abi().og_ui_text_field(id, value, json(desc))),
1506
+ textArea: (id: string, value: string, desc?: unknown) => parse(abi().og_ui_text_area(id, value, json(desc))),
1507
+ tooltip: (text: string) => abi().og_ui_tooltip(text),
1508
+ idi: (name: string, index: number) => `${name}#${index}`,
1509
+ });