@origingame/engine 0.0.0-stage → 0.1.1

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 (159) 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/gitignore +8 -0
  42. package/knowledge/scaffold/template/package.json +18 -0
  43. package/knowledge/scaffold/template/project.ogt +25 -0
  44. package/knowledge/scaffold/template/src/main.ts +246 -0
  45. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  46. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  47. package/knowledge/scaffold/template/tsconfig.json +17 -0
  48. package/knowledge/scripts/lib.ts +207 -0
  49. package/knowledge/scripts/mount.ts +210 -0
  50. package/knowledge/scripts/pack.ts +25 -0
  51. package/knowledge/scripts/scaffold.ts +72 -0
  52. package/knowledge/scripts/validate.ts +749 -0
  53. package/knowledge/skills/og-ai/SKILL.md +386 -0
  54. package/knowledge/skills/og-audio/SKILL.md +191 -0
  55. package/knowledge/skills/og-build/SKILL.md +330 -0
  56. package/knowledge/skills/og-core/SKILL.md +285 -0
  57. package/knowledge/skills/og-feel/SKILL.md +385 -0
  58. package/knowledge/skills/og-game/SKILL.md +1479 -0
  59. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  60. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  61. package/knowledge/skills/og-taste/SKILL.md +537 -0
  62. package/knowledge/skills/og-ui/SKILL.md +444 -0
  63. package/knowledge/wiki/ai.md +627 -0
  64. package/knowledge/wiki/anim.md +1187 -0
  65. package/knowledge/wiki/atmosphere.md +636 -0
  66. package/knowledge/wiki/audio.md +732 -0
  67. package/knowledge/wiki/camera.md +711 -0
  68. package/knowledge/wiki/commands.md +357 -0
  69. package/knowledge/wiki/game.md +220 -0
  70. package/knowledge/wiki/i18n.md +213 -0
  71. package/knowledge/wiki/index.md +1027 -0
  72. package/knowledge/wiki/input.md +1714 -0
  73. package/knowledge/wiki/load.md +1280 -0
  74. package/knowledge/wiki/math.md +166 -0
  75. package/knowledge/wiki/nav.md +1154 -0
  76. package/knowledge/wiki/physics.md +3142 -0
  77. package/knowledge/wiki/platform.md +857 -0
  78. package/knowledge/wiki/probe.md +1692 -0
  79. package/knowledge/wiki/reflect.md +285 -0
  80. package/knowledge/wiki/render.md +3511 -0
  81. package/knowledge/wiki/rng.md +393 -0
  82. package/knowledge/wiki/stream.md +376 -0
  83. package/knowledge/wiki/system.md +197 -0
  84. package/knowledge/wiki/systems.md +43 -0
  85. package/knowledge/wiki/terrain.md +727 -0
  86. package/knowledge/wiki/tilemap.md +422 -0
  87. package/knowledge/wiki/time.md +150 -0
  88. package/knowledge/wiki/transform.md +230 -0
  89. package/knowledge/wiki/tween.md +580 -0
  90. package/knowledge/wiki/ui.md +8000 -0
  91. package/knowledge/wiki/world.md +909 -0
  92. package/package.json +68 -4
  93. package/schema/api.schema.json +29675 -0
  94. package/shell/README.md +22 -0
  95. package/shell/brand/README.md +23 -0
  96. package/shell/brand/icon-192.png +0 -0
  97. package/shell/brand/icon-512.png +0 -0
  98. package/shell/brand/mark-on-paper.svg +6 -0
  99. package/shell/brand/origin-mark.svg +5 -0
  100. package/shell/brand/origin-motion.css +251 -0
  101. package/shell/brand/tokens.css +20 -0
  102. package/shell/brand.ts +132 -0
  103. package/shell/page.ts +715 -0
  104. package/shell/platform.ts +203 -0
  105. package/shell/player.ts +238 -0
  106. package/shell/shell.css +185 -0
  107. package/shell/sw.ts +177 -0
  108. package/shell/transport.ts +515 -0
  109. package/shell/wire.ts +369 -0
  110. package/src/e6.ts +69 -0
  111. package/src/env.d.ts +100 -0
  112. package/src/generated/abi.ts +980 -0
  113. package/src/index.ts +193 -0
  114. package/src/node/audio.ts +86 -0
  115. package/src/node/build.ts +109 -0
  116. package/src/node/device.ts +981 -0
  117. package/src/node/doctor.ts +60 -0
  118. package/src/node/index.ts +102 -0
  119. package/src/node/platform.ts +24 -0
  120. package/src/node/playtest-run.ts +249 -0
  121. package/src/node/png.ts +130 -0
  122. package/src/node/polish.ts +976 -0
  123. package/src/node/run.ts +373 -0
  124. package/src/node/web-build.ts +808 -0
  125. package/src/p2.ts +35 -0
  126. package/src/platform-local.ts +211 -0
  127. package/src/playtest.ts +131 -0
  128. package/src/runtime.ts +1509 -0
  129. package/src/systems/cameras.ts +247 -0
  130. package/src/ui-kit/bind.ts +141 -0
  131. package/src/ui-kit/huds.ts +303 -0
  132. package/src/ui-kit/index.ts +36 -0
  133. package/src/ui-kit/menus.ts +187 -0
  134. package/src/ui-kit/settings.ts +131 -0
  135. package/src/ui-kit/store.ts +222 -0
  136. package/src/ui-kit/story.ts +89 -0
  137. package/src/ui-kit/util.ts +106 -0
  138. package/src/web/a11y.ts +429 -0
  139. package/src/web/audio-worklet.ts +193 -0
  140. package/src/web/audio.ts +307 -0
  141. package/src/web/basis.ts +239 -0
  142. package/src/web/gamepad.ts +123 -0
  143. package/src/web/host.ts +166 -0
  144. package/src/web/input.ts +237 -0
  145. package/src/web/lanes.ts +21 -0
  146. package/src/web/play.ts +20 -0
  147. package/src/web/rpc-ring.ts +110 -0
  148. package/src/web/settle.ts +122 -0
  149. package/src/web/shell.ts +643 -0
  150. package/src/web/text-input.ts +248 -0
  151. package/src/web/worker.ts +1002 -0
  152. package/types/bindings.json +13654 -0
  153. package/types/index.d.ts +20090 -0
  154. package/vendor/basis/LICENSE +201 -0
  155. package/vendor/basis/NOTICE +15 -0
  156. package/vendor/basis/README.md +18 -0
  157. package/vendor/basis/basis_transcoder.js +19 -0
  158. package/vendor/basis/basis_transcoder.wasm +0 -0
  159. package/README.md +0 -3
@@ -0,0 +1,166 @@
1
+ // The web host binding: the wasm-bindgen module of crates/og-host-web
2
+ // (origin-engine.js + origin-engine_bg.wasm, `--target web`). Column views
3
+ // are typed arrays over the module's `memory.buffer` at the byte offsets the
4
+ // host answers, so a system writes engine memory directly. Runs in the engine
5
+ // Worker (worker.ts) and anywhere else wasm runs (the cross-host test runs it
6
+ // under bun).
7
+
8
+ import { ARRAYS, bindHost, boundHost, type ColumnArray, type HostBinding } from "../runtime.ts";
9
+ import type { HostAbi } from "../generated/abi.ts";
10
+ import bindings from "../../types/bindings.json" with { type: "json" };
11
+
12
+ interface CallDecl {
13
+ readonly symbol: string;
14
+ readonly module: string;
15
+ readonly returns: string;
16
+ readonly publish: boolean;
17
+ }
18
+
19
+ /**
20
+ * A publish build exports only the player-facing members (the commands
21
+ * module and the probe's other members are compiled out). The runtime binds
22
+ * every symbol, so each missing non-publish symbol answers its refusal: the
23
+ * probe's `null` (or `false`), a command's `{ ok: false }`.
24
+ */
25
+ export function withPublishRefusals(glue: Record<string, unknown>): Record<string, unknown> {
26
+ const missing = (bindings.calls as CallDecl[]).filter((c) => !c.publish && typeof glue[c.symbol] !== "function");
27
+ if (missing.length === 0) return glue;
28
+ const out: Record<string, unknown> = { ...glue };
29
+ const reason = JSON.stringify({ ok: false, reason: "not in a publish build: the command channel and the probe's development members are compiled out" });
30
+ for (const c of missing) {
31
+ const answer: unknown =
32
+ c.returns === "bool" ? false
33
+ : c.returns === "u32" || c.returns === "i32" ? 0
34
+ : c.returns.startsWith("List<") ? "[]"
35
+ : c.module === "probe" ? "null"
36
+ : reason;
37
+ out[c.symbol] = () => answer;
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** The wasm-bindgen glue module: its default export initialises it. */
43
+ export interface WebGlue extends Record<string, unknown> {
44
+ default(input?: { module_or_path: unknown }): Promise<{ memory: WebAssembly.Memory }>;
45
+ og_web_init(): void;
46
+ /** The lanes variant only (wasm-bindgen-rayon): starts `threads` Workers sharing the module's memory. */
47
+ initThreadPool?(threads: number): Promise<unknown>;
48
+ /** The lanes variant only: lanes run on the pool from now on (crates/og-lanes). */
49
+ og_lanes_ready?(threads: number): void;
50
+ }
51
+
52
+ /**
53
+ * How long the lanes pool may take to start. wasm-bindgen-rayon waits for
54
+ * each pool Worker's ready message with no timeout and no error handler, so
55
+ * a Worker that never loads (a `worker-src` that refuses `blob:`, a failed
56
+ * module import inside it) would otherwise hang the boot.
57
+ */
58
+ export const LANES_POOL_DEADLINE_MS = 10_000;
59
+
60
+ /**
61
+ * Runs `start` (the glue's `initThreadPool`) and settles when it does, when a
62
+ * Worker it creates fires `error`, or at the deadline, whichever comes first.
63
+ * While it runs, `globalThis.Worker` is wrapped to record the pool's Workers;
64
+ * on a failure they are terminated. The pool can still be creating Workers
65
+ * after a failure (wasm-bindgen-rayon fetches its script per Worker), so the
66
+ * wrapper stays until `start` settles and terminates each late one; a pool
67
+ * that never settles leaves it for the life of the engine Worker, which
68
+ * starts no other Workers.
69
+ */
70
+ async function startPool(start: () => Promise<unknown>, deadlineMs: number): Promise<void> {
71
+ const scope = globalThis as { Worker?: typeof Worker };
72
+ const Original = scope.Worker;
73
+ if (typeof Original !== "function") {
74
+ await start();
75
+ return;
76
+ }
77
+ const workers: Worker[] = [];
78
+ let abandoned = false;
79
+ let fail: (e: Error) => void = () => {};
80
+ const failure = new Promise<never>((_, reject) => (fail = reject));
81
+ failure.catch(() => {});
82
+ scope.Worker = new Proxy(Original, {
83
+ construct(target, args, newTarget) {
84
+ const w = Reflect.construct(target, args, newTarget) as Worker;
85
+ if (abandoned) {
86
+ w.terminate();
87
+ return w;
88
+ }
89
+ workers.push(w);
90
+ w.addEventListener("error", (e) => {
91
+ const message = (e as ErrorEvent).message;
92
+ fail(new Error(`a lanes pool Worker failed to load${message ? ` (${message})` : ""}`));
93
+ });
94
+ return w;
95
+ },
96
+ });
97
+ const pool = Promise.resolve().then(start);
98
+ const restore = () => {
99
+ scope.Worker = Original;
100
+ };
101
+ let timer: ReturnType<typeof setTimeout> | undefined;
102
+ const deadline = new Promise<never>((_, reject) => {
103
+ timer = setTimeout(() => reject(new Error(`the lanes pool did not start within ${deadlineMs / 1000} s`)), deadlineMs);
104
+ });
105
+ try {
106
+ await Promise.race([pool, failure, deadline]);
107
+ restore();
108
+ } catch (e) {
109
+ abandoned = true;
110
+ for (const w of workers) w.terminate();
111
+ pool.then(restore, restore);
112
+ throw e;
113
+ } finally {
114
+ clearTimeout(timer);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Initialise the glue with the wasm (a URL, a Response or the bytes) and bind
120
+ * the runtime to it. With `threads`, the glue is the lanes variant and its
121
+ * Worker pool starts first, within {@link LANES_POOL_DEADLINE_MS}; a failure
122
+ * there (an error, a pool Worker's `error` event or the deadline) terminates
123
+ * the pool's Workers and throws before the engine is told or anything is
124
+ * bound, so the caller can discard this glue and load the single-threaded
125
+ * variant instead.
126
+ */
127
+ export async function loadWebHost(glue: WebGlue, wasm?: unknown, threads?: number): Promise<HostBinding> {
128
+ const bound = boundHost();
129
+ if (bound) return bound;
130
+ const exports = await glue.default(wasm === undefined ? undefined : { module_or_path: wasm });
131
+ if (threads !== undefined) {
132
+ if (!glue.initThreadPool || !glue.og_lanes_ready) throw new Error("the module is not the lanes variant (no initThreadPool)");
133
+ const init = glue.initThreadPool;
134
+ await startPool(() => init(threads), LANES_POOL_DEADLINE_MS);
135
+ glue.og_lanes_ready(threads);
136
+ }
137
+ glue.og_web_init();
138
+ const memory = exports.memory;
139
+ // Arrays by (offset, length, kind) over the current buffer: a system's
140
+ // views cost no allocation while its columns stay put.
141
+ let cache = new Map<string, ColumnArray>();
142
+ let cachedBuffer: ArrayBufferLike = memory.buffer;
143
+ const binding: HostBinding = {
144
+ abi: withPublishRefusals(glue) as unknown as HostAbi<Uint32Array, unknown>,
145
+ name: "web",
146
+ columns(raw, kinds, count) {
147
+ const offsets = raw as Uint32Array;
148
+ if (!offsets || offsets.length === 0) return null;
149
+ if (cachedBuffer !== memory.buffer || cache.size > 4096) {
150
+ cache = new Map();
151
+ cachedBuffer = memory.buffer;
152
+ }
153
+ return Array.from(offsets, (offset, i) => {
154
+ const kind = kinds[i]!;
155
+ const key = `${offset}:${count * kind.stride}:${kind.array}`;
156
+ let a = cache.get(key);
157
+ if (!a) cache.set(key, (a = new ARRAYS[kind.array](memory.buffer, offset, count * kind.stride) as ColumnArray));
158
+ return a;
159
+ });
160
+ },
161
+ // A grown memory detaches every array over the old buffer.
162
+ fresh: (array) => array.buffer === memory.buffer,
163
+ };
164
+ bindHost(binding);
165
+ return binding;
166
+ }
@@ -0,0 +1,237 @@
1
+ // The input ring: the main thread writes page input into a SharedArrayBuffer
2
+ // the engine Worker drains once per frame (docs/design.md §2.2). One slot is
3
+ // 16 int32: kind, action, button, key length, x, y, deltaX, deltaY (as f32
4
+ // bits), then up to 32 bytes of the key code; a pointer slot carries its
5
+ // engine pointer id (0 the primary, 1 to 4 further touches) in word 8
6
+ // instead. A gamepad slot (kind 3) is a pad's whole state: word 2 the pad,
7
+ // word 3 connected, words 4-7 the axes, word 8 the button mask, words 9-10
8
+ // the triggers. A text slot (kind 4) carries typed or composed text: word 1
9
+ // 0 committed or 1 composing, word 2 1 when it continues the slot before it
10
+ // (text past 48 bytes is split between characters over several slots,
11
+ // joined again by drainRing), word 3 the UTF-8 length, the text in words
12
+ // 4-15. An a11y slot (kind 5) is laid out as a text slot with word 1 the
13
+ // action (A11Y_ACTIONS) and the control's path as its text. A key or pointer slot carries the event's time in words
14
+ // 6-7 (an f64: performance.timeOrigin + the event's timeStamp, ms; 0 when
15
+ // unknown), which the Worker turns into the event's `at` within the tick.
16
+ // Without cross-origin isolation the shell posts the same events as
17
+ // messages instead. crates/og-input/src/ring.rs decodes the same layout.
18
+
19
+ /** An event as the probe's input() and the engine's input layer take it (IDL InjectedInput). */
20
+ export type InjectedInput =
21
+ | {
22
+ kind: "key";
23
+ key: string;
24
+ action: "press" | "down" | "up";
25
+ /** The page's clock when it happened (performance.timeOrigin + timeStamp, ms): the Worker turns it into `at`. */
26
+ time?: number;
27
+ /** When it happened, in ticks from the start of the tick that reads it (-1 to 1; IDL KeyInput.at). */
28
+ at?: number;
29
+ }
30
+ | {
31
+ kind: "pointer";
32
+ action: "click" | "move" | "down" | "up";
33
+ x: number;
34
+ y: number;
35
+ button: "none" | "left" | "middle" | "right";
36
+ /** Which pointer: 0 the primary (omitted), 1 to 4 further touches (multi-touch). */
37
+ id?: number;
38
+ time?: number;
39
+ at?: number;
40
+ }
41
+ | { kind: "wheel"; x: number; y: number; deltaX: number; deltaY: number }
42
+ | {
43
+ kind: "gamepad";
44
+ pad: number;
45
+ connected: boolean;
46
+ /** Held standard-mapping buttons by name (GAMEPAD_BUTTONS). */
47
+ buttons: GamepadButtonName[];
48
+ /** Left x, y, right x, y; -1..1, y down. */
49
+ axes: number[];
50
+ /** Left and right analog triggers, 0..1. */
51
+ triggers: number[];
52
+ }
53
+ | {
54
+ kind: "text";
55
+ /** Typed text (a key's character, a paste, an IME commit), or with `composing` an input method's composition so far (empty ends it). */
56
+ text: string;
57
+ composing?: boolean;
58
+ }
59
+ | {
60
+ kind: "a11y";
61
+ /** What a screen reader asked of the UI control at `path` (a11y.ts): focus it, activate it, or step an adjustable one. */
62
+ action: A11yActionName;
63
+ /** The control's path, as og.ui.a11y() names it. */
64
+ path: string;
65
+ };
66
+
67
+ export type A11yActionName = (typeof A11Y_ACTIONS)[number];
68
+
69
+ /** og-input's A11yAct, by index. */
70
+ export const A11Y_ACTIONS = ["focus", "activate", "increment", "decrement"] as const;
71
+
72
+ export type GamepadButtonName = (typeof GAMEPAD_BUTTONS)[number];
73
+
74
+ /** The W3C standard mapping's buttons, by index (og-input's GAMEPAD_BUTTONS). */
75
+ export const GAMEPAD_BUTTONS = [
76
+ "a", "b", "x", "y", "leftBumper", "rightBumper", "leftTrigger", "rightTrigger",
77
+ "back", "start", "leftStick", "rightStick", "up", "down", "left", "right", "home",
78
+ ] as const;
79
+
80
+ const SLOT = 16;
81
+ const HEADER = 2; // write index, read index (monotonic)
82
+ export const RING_SLOTS = 256;
83
+
84
+ const KINDS = ["key", "pointer", "wheel", "gamepad", "text", "a11y"] as const;
85
+ /** Bytes of text one slot holds (words 4-15). */
86
+ const TEXT_BYTES = 48;
87
+
88
+ /** UTF-8 pieces of at most TEXT_BYTES, split between characters. */
89
+ function textPieces(text: string): Uint8Array[] {
90
+ const encoder = new TextEncoder();
91
+ const out: Uint8Array[] = [];
92
+ let piece = "";
93
+ for (const ch of text) {
94
+ if (encoder.encode(piece + ch).length > TEXT_BYTES) {
95
+ out.push(encoder.encode(piece));
96
+ piece = "";
97
+ }
98
+ piece += ch;
99
+ }
100
+ out.push(encoder.encode(piece));
101
+ return out;
102
+ }
103
+ const ACTIONS = ["press", "down", "up", "click", "move"] as const;
104
+ const BUTTONS = ["none", "left", "middle", "right"] as const;
105
+
106
+ export function createRing(): SharedArrayBuffer {
107
+ return new SharedArrayBuffer((HEADER + RING_SLOTS * SLOT) * 4);
108
+ }
109
+
110
+ /** Main thread: append one event; false when the ring is full (the event is dropped). */
111
+ export function writeRing(buffer: SharedArrayBuffer, event: InjectedInput): boolean {
112
+ const ints = new Int32Array(buffer);
113
+ const floats = new Float32Array(buffer);
114
+ const write = Atomics.load(ints, 0);
115
+ const read = Atomics.load(ints, 1);
116
+ if (write - read >= RING_SLOTS) return false;
117
+ if (event.kind === "text" || event.kind === "a11y") {
118
+ // An a11y slot is a text slot whose text is the control's path.
119
+ const pieces = textPieces(event.kind === "text" ? event.text : event.path);
120
+ if (write - read + pieces.length > RING_SLOTS) return false;
121
+ pieces.forEach((bytes, k) => {
122
+ const at = HEADER + ((write + k) % RING_SLOTS) * SLOT;
123
+ new Int32Array(buffer, at * 4, SLOT).fill(0);
124
+ ints[at] = KINDS.indexOf(event.kind);
125
+ ints[at + 1] = event.kind === "text" ? (event.composing ? 1 : 0) : A11Y_ACTIONS.indexOf(event.action);
126
+ ints[at + 2] = k > 0 ? 1 : 0;
127
+ ints[at + 3] = bytes.length;
128
+ new Uint8Array(buffer, (at + 4) * 4, TEXT_BYTES).set(bytes);
129
+ });
130
+ Atomics.store(ints, 0, write + pieces.length);
131
+ return true;
132
+ }
133
+ const at = HEADER + (write % RING_SLOTS) * SLOT;
134
+ new Int32Array(buffer, at * 4, SLOT).fill(0);
135
+ ints[at] = KINDS.indexOf(event.kind);
136
+ if (event.kind === "gamepad") {
137
+ ints[at + 2] = event.pad;
138
+ ints[at + 3] = event.connected ? 1 : 0;
139
+ for (let i = 0; i < 4; i++) floats[at + 4 + i] = event.axes[i] ?? 0;
140
+ ints[at + 8] = event.buttons.reduce((m, b) => m | (1 << GAMEPAD_BUTTONS.indexOf(b)), 0);
141
+ floats[at + 9] = event.triggers[0] ?? 0;
142
+ floats[at + 10] = event.triggers[1] ?? 0;
143
+ Atomics.store(ints, 0, write + 1);
144
+ return true;
145
+ }
146
+ ints[at + 1] = "action" in event ? ACTIONS.indexOf(event.action) : 0;
147
+ ints[at + 2] = event.kind === "pointer" ? BUTTONS.indexOf(event.button) : 0;
148
+ const key = event.kind === "key" ? new TextEncoder().encode(event.key).subarray(0, 32) : new Uint8Array(0);
149
+ ints[at + 3] = key.length;
150
+ floats[at + 4] = "x" in event ? event.x : 0;
151
+ floats[at + 5] = "y" in event ? event.y : 0;
152
+ floats[at + 6] = event.kind === "wheel" ? event.deltaX : 0;
153
+ floats[at + 7] = event.kind === "wheel" ? event.deltaY : 0;
154
+ new Uint8Array(buffer, (at + 8) * 4, 32).fill(0).set(key);
155
+ if (event.kind === "pointer") ints[at + 8] = event.id ?? 0;
156
+ if ((event.kind === "key" || event.kind === "pointer") && event.time !== undefined) new Float64Array(buffer, (at + 6) * 4, 1)[0] = event.time;
157
+ Atomics.store(ints, 0, write + 1);
158
+ return true;
159
+ }
160
+
161
+ /** Worker: every event written since the last drain, oldest first. */
162
+ export function drainRing(buffer: SharedArrayBuffer): InjectedInput[] {
163
+ const ints = new Int32Array(buffer);
164
+ const floats = new Float32Array(buffer);
165
+ const write = Atomics.load(ints, 0);
166
+ let read = Atomics.load(ints, 1);
167
+ const out: InjectedInput[] = [];
168
+ for (; read < write; read++) {
169
+ const at = HEADER + (read % RING_SLOTS) * SLOT;
170
+ const kind = KINDS[ints[at]!];
171
+ if (kind === "text") {
172
+ const text = new TextDecoder().decode(new Uint8Array(buffer, (at + 4) * 4, ints[at + 3]!).slice());
173
+ const composing = ints[at + 1] === 1;
174
+ const last = out[out.length - 1];
175
+ if (ints[at + 2] === 1 && last?.kind === "text" && (last.composing ?? false) === composing) last.text += text;
176
+ else out.push(composing ? { kind, text, composing } : { kind, text });
177
+ continue;
178
+ }
179
+ if (kind === "a11y") {
180
+ const path = new TextDecoder().decode(new Uint8Array(buffer, (at + 4) * 4, ints[at + 3]!).slice());
181
+ const action = A11Y_ACTIONS[ints[at + 1]!] ?? "focus";
182
+ const last = out[out.length - 1];
183
+ if (ints[at + 2] === 1 && last?.kind === "a11y" && last.action === action) last.path += path;
184
+ else out.push({ kind, action, path });
185
+ continue;
186
+ }
187
+ if (kind === "gamepad") {
188
+ const mask = ints[at + 8]!;
189
+ out.push({
190
+ kind,
191
+ pad: ints[at + 2]!,
192
+ connected: ints[at + 3]! !== 0,
193
+ buttons: GAMEPAD_BUTTONS.filter((_, i) => mask & (1 << i)),
194
+ axes: [floats[at + 4]!, floats[at + 5]!, floats[at + 6]!, floats[at + 7]!],
195
+ triggers: [floats[at + 9]!, floats[at + 10]!],
196
+ });
197
+ continue;
198
+ }
199
+ const action = ACTIONS[ints[at + 1]!]!;
200
+ const x = floats[at + 4]!;
201
+ const y = floats[at + 5]!;
202
+ const time = kind === "key" || kind === "pointer" ? new Float64Array(buffer, (at + 6) * 4, 1)[0]! : 0;
203
+ const stamped = time > 0 ? { time } : {};
204
+ if (kind === "key") {
205
+ const bytes = new Uint8Array(buffer, (at + 8) * 4, ints[at + 3]!).slice();
206
+ out.push({ kind, key: new TextDecoder().decode(bytes), action: action as "press" | "down" | "up", ...stamped });
207
+ } else if (kind === "pointer") {
208
+ const id = ints[at + 8]!;
209
+ out.push({
210
+ kind,
211
+ action: action as "click" | "move" | "down" | "up",
212
+ x,
213
+ y,
214
+ button: BUTTONS[ints[at + 2]!]!,
215
+ ...(id > 0 ? { id } : {}),
216
+ ...stamped,
217
+ });
218
+ } else if (kind === "wheel") {
219
+ out.push({ kind, x, y, deltaX: floats[at + 6]!, deltaY: floats[at + 7]! });
220
+ }
221
+ }
222
+ Atomics.store(ints, 1, read);
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * `at` of an event that happened at `time` (the page's clock, ms) and is
228
+ * injected now, before the tick that reads it: the sim clock is `behind` ms
229
+ * (the accumulator the last frame left, plus what elapsed since) ahead of the
230
+ * last tick's start, so the event falls at `(behind - age) / tickMs` ticks
231
+ * into the next tick, clamped to -1..1. 0 without a time.
232
+ */
233
+ export function stampAt(time: number | undefined, now: number, behind: number, tickMs: number): number {
234
+ if (time === undefined || !(time > 0) || !(tickMs > 0)) return 0;
235
+ const at = (behind - (now - time)) / tickMs;
236
+ return Number.isFinite(at) ? Math.max(-1, Math.min(1, at)) : 0;
237
+ }
@@ -0,0 +1,21 @@
1
+ // The boot rule for the lanes variant (docs/web-export.md §Lanes), shared by
2
+ // the shell, which starts the variant it picks, and the exported game's
3
+ // service worker (shell/sw.ts), which precaches the variant this rule will
4
+ // pick, so the two never disagree.
5
+
6
+ /** The most Workers lanes take beside the engine Worker. */
7
+ export const LANES_MAX_THREADS = 8;
8
+
9
+ /**
10
+ * Lanes or not, decided at boot: the lanes variant needs shared memory
11
+ * (`crossOriginIsolated`) and more than two logical cores. The pool takes
12
+ * the cores but one, at most LANES_MAX_THREADS: the core left is the page's,
13
+ * and the engine Worker needs none of its own while a lane runs, since it
14
+ * waits for the pool (rayon blocks the calling thread until the lane ends).
15
+ */
16
+ export function laneChoice(offered: boolean, isolated: boolean, cores: number): { threads: number } | { why: string } {
17
+ if (!offered) return { why: "the build has no lanes variant" };
18
+ if (!isolated) return { why: "the page is not cross-origin isolated (COOP/COEP)" };
19
+ if (!(cores > 2)) return { why: `${cores || "unknown"} logical cores: single-threaded` };
20
+ return { threads: Math.min(cores - 1, LANES_MAX_THREADS) };
21
+ }
@@ -0,0 +1,20 @@
1
+ // The play page `og build` ships (dist/index.html loads dist/play.js): the
2
+ // player shell of shell/page.ts on the page's canvas, the engine Worker
3
+ // (dist/game.js), the cooked bundle next to the page, the Basis transcoder
4
+ // under basis/ and the audio worklet (dist/audio-worklet.js).
5
+ //
6
+ // - A `replay-seed` query parameter starts a deterministic replay from that
7
+ // uint32 seed; `webgl=1` takes the WebGL2 fallback; `autoplay=0` leaves the
8
+ // clock to the probe (no gate, no play), as test harnesses drive it.
9
+ // - The probe is published on `globalThis.__runtime_probe__`: every member
10
+ // answers a Promise (the call crosses the request ring to the Worker, or
11
+ // postMessage without cross-origin isolation), and the platform's og-probe
12
+ // wire is served over it (shell/wire.ts).
13
+
14
+ import { play } from "../../shell/page.ts";
15
+
16
+ export { play };
17
+
18
+ void play().catch(() => {
19
+ /* the error card is already on screen */
20
+ });
@@ -0,0 +1,110 @@
1
+ // The probe's request ring (docs/design.md §3.4): the shell calls the engine
2
+ // Worker without a postMessage hop. Two SharedArrayBuffer byte rings, one for
3
+ // request frames (shell → Worker) and one for answer frames (Worker →
4
+ // shell), each carrying JSON-RPC 2.0 text framed as a little-endian u32
5
+ // length then UTF-8. The Worker drains requests between ticks and answers
6
+ // each with `og_engine_call`; the shell wakes on Atomics.notify.
7
+ //
8
+ // Layout of one ring: Int32 [0] write and [1] read byte counters (monotonic,
9
+ // unsigned 32-bit arithmetic), [2] a notify word, then `capacity` data bytes.
10
+ // A frame longer than a ring's capacity cannot cross it; `writeFrame`
11
+ // answers false and the caller falls back to postMessage.
12
+
13
+ const HEADER = 16;
14
+
15
+ /** The shell's and the Worker's halves share these two buffers. */
16
+ export interface RpcRings {
17
+ readonly requests: SharedArrayBuffer;
18
+ readonly answers: SharedArrayBuffer;
19
+ }
20
+
21
+ /** Requests are small; answers carry capture PNGs, so they get more room. */
22
+ export function createRpcRings(requestBytes = 1 << 20, answerBytes = 16 << 20): RpcRings {
23
+ return {
24
+ requests: new SharedArrayBuffer(HEADER + requestBytes),
25
+ answers: new SharedArrayBuffer(HEADER + answerBytes),
26
+ };
27
+ }
28
+
29
+ function capacity(ring: SharedArrayBuffer): number {
30
+ return ring.byteLength - HEADER;
31
+ }
32
+
33
+ function copyIn(ring: SharedArrayBuffer, at: number, bytes: Uint8Array): void {
34
+ const cap = capacity(ring);
35
+ const data = new Uint8Array(ring, HEADER, cap);
36
+ const start = at % cap;
37
+ const first = Math.min(bytes.length, cap - start);
38
+ data.set(bytes.subarray(0, first), start);
39
+ if (first < bytes.length) data.set(bytes.subarray(first), 0);
40
+ }
41
+
42
+ function copyOut(ring: SharedArrayBuffer, at: number, length: number): Uint8Array {
43
+ const cap = capacity(ring);
44
+ const data = new Uint8Array(ring, HEADER, cap);
45
+ const out = new Uint8Array(length);
46
+ const start = at % cap;
47
+ const first = Math.min(length, cap - start);
48
+ out.set(data.subarray(start, start + first), 0);
49
+ if (first < length) out.set(data.subarray(0, length - first), first);
50
+ return out;
51
+ }
52
+
53
+ const encoder = new TextEncoder();
54
+ const decoder = new TextDecoder();
55
+
56
+ /** Append one frame; false when it does not fit now (or ever: longer than the ring). */
57
+ export function writeFrame(ring: SharedArrayBuffer, text: string): boolean {
58
+ const ints = new Int32Array(ring, 0, 4);
59
+ const body = encoder.encode(text);
60
+ const need = 4 + body.length;
61
+ const write = Atomics.load(ints, 0) >>> 0;
62
+ const read = Atomics.load(ints, 1) >>> 0;
63
+ if (need > capacity(ring) - ((write - read) >>> 0)) return false;
64
+ const len = new Uint8Array(4);
65
+ new DataView(len.buffer).setUint32(0, body.length, true);
66
+ copyIn(ring, write, len);
67
+ copyIn(ring, (write + 4) >>> 0, body);
68
+ Atomics.store(ints, 0, (write + need) | 0);
69
+ Atomics.add(ints, 2, 1);
70
+ Atomics.notify(ints, 2);
71
+ return true;
72
+ }
73
+
74
+ /** Every frame written since the last read, oldest first. */
75
+ export function readFrames(ring: SharedArrayBuffer): string[] {
76
+ const ints = new Int32Array(ring, 0, 4);
77
+ const write = Atomics.load(ints, 0) >>> 0;
78
+ let read = Atomics.load(ints, 1) >>> 0;
79
+ const out: string[] = [];
80
+ while (read !== write) {
81
+ const length = new DataView(copyOut(ring, read, 4).buffer).getUint32(0, true);
82
+ out.push(decoder.decode(copyOut(ring, (read + 4) >>> 0, length)));
83
+ read = (read + 4 + length) >>> 0;
84
+ }
85
+ Atomics.store(ints, 1, read | 0);
86
+ return out;
87
+ }
88
+
89
+ /** Whether frames are waiting. */
90
+ export function hasFrames(ring: SharedArrayBuffer): boolean {
91
+ const ints = new Int32Array(ring, 0, 4);
92
+ return Atomics.load(ints, 0) !== Atomics.load(ints, 1);
93
+ }
94
+
95
+ type WaitAsync = (a: Int32Array, i: number, v: number, t?: number) => { async: boolean; value: Promise<string> | string };
96
+
97
+ /**
98
+ * Resolves when the ring has frames or `timeoutMs` passed. Uses
99
+ * Atomics.waitAsync where the platform has it (no polling), else a timer.
100
+ */
101
+ export function waitForFrames(ring: SharedArrayBuffer, timeoutMs = 50): Promise<void> {
102
+ if (hasFrames(ring)) return Promise.resolve();
103
+ const ints = new Int32Array(ring, 0, 4);
104
+ const waitAsync = (Atomics as unknown as { waitAsync?: WaitAsync }).waitAsync;
105
+ if (waitAsync) {
106
+ const r = waitAsync(ints, 2, Atomics.load(ints, 2), timeoutMs);
107
+ return r.async ? (r.value as Promise<string>).then(() => undefined) : Promise.resolve();
108
+ }
109
+ return new Promise((resolve) => setTimeout(resolve, Math.min(timeoutMs, 4)));
110
+ }
@@ -0,0 +1,122 @@
1
+ // The platform's first answers behind the play gate (docs/web-export.md
2
+ // §The shell). The shell takes the game's first tick behind the gate and
3
+ // holds the clock; og.platform's answers apply at the start of the next
4
+ // tick, so without more ticks a returning player's title would show the
5
+ // defaults (English, no Continue) until the tap. So after the first tick
6
+ // the Worker waits for the host's answers to the calls it made and takes one
7
+ // tick to apply them, then again for the calls that tick made (a slot's
8
+ // parts after its manifest), until a tick asks the host nothing new, at
9
+ // most SETTLE_TICKS ticks, before it draws the first frame (within
10
+ // SETTLE_FIRST_MS) and after it (within SETTLE_MS, each settling tick
11
+ // drawn). The ticks are the game's own ticks, and the answers reach the
12
+ // world as every answer does, at a tick's start, as host input a replay
13
+ // repeats; the gate's tap ends the settling, and play goes on from there.
14
+
15
+ import type { PlatformAnswer, PlatformCall, PlatformHost } from "../platform-local.ts";
16
+
17
+ /** The most ticks the Worker takes behind the gate to apply the platform's answers. */
18
+ export const SETTLE_TICKS = 8;
19
+ /** How long the first frame waits for the first tick's answers, ms. */
20
+ export const SETTLE_FIRST_MS = 300;
21
+ /** How long, from the first tick, the Worker keeps applying answers behind the gate, ms. */
22
+ export const SETTLE_MS = 5000;
23
+
24
+ /**
25
+ * Counts what a platform host was asked and what it answered: the calls
26
+ * sent to it (telemetry is never answered) and the answers that arrived
27
+ * since the last tick took them.
28
+ */
29
+ export class PlatformCounter {
30
+ #sent = 0;
31
+ #answered = 0;
32
+ #fresh = false;
33
+ #wake: (() => void) | null = null;
34
+
35
+ /** The host, counting the calls it is handed. */
36
+ wrap(host: PlatformHost): PlatformHost {
37
+ return {
38
+ host: host.host,
39
+ capabilities: host.capabilities,
40
+ handle: (calls: readonly PlatformCall[], answer: (request: number, result: PlatformAnswer) => void) => {
41
+ this.sent(calls);
42
+ host.handle(calls, (request, result) => {
43
+ this.answered();
44
+ answer(request, result);
45
+ });
46
+ },
47
+ };
48
+ }
49
+
50
+ /** Calls handed to the host. */
51
+ sent(calls: readonly PlatformCall[]): void {
52
+ this.#sent += calls.filter((c) => c.method !== "telemetry").length;
53
+ }
54
+
55
+ /** One answer arrived (the engine has it; the next tick applies it). */
56
+ answered(): void {
57
+ this.#answered++;
58
+ this.#fresh = true;
59
+ if (this.waiting === 0) this.#wake?.();
60
+ }
61
+
62
+ /** Calls the host has not answered yet. */
63
+ get waiting(): number {
64
+ return Math.max(0, this.#sent - this.#answered);
65
+ }
66
+
67
+ /** Whether an answer arrived since the last `took`. */
68
+ get fresh(): boolean {
69
+ return this.#fresh;
70
+ }
71
+
72
+ /** A tick took the answers that had arrived. */
73
+ took(): void {
74
+ this.#fresh = false;
75
+ }
76
+
77
+ /** Resolves when the host answered every call it was handed, or at `until` (performance.now()). */
78
+ quiet(until: number): Promise<void> {
79
+ if (this.waiting === 0) return Promise.resolve();
80
+ return new Promise((resolve) => {
81
+ const done = () => {
82
+ clearTimeout(timer);
83
+ if (this.#wake === done) this.#wake = null;
84
+ resolve();
85
+ };
86
+ const timer = setTimeout(done, Math.max(0, until - performance.now()));
87
+ this.#wake = done;
88
+ });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Behind the gate: while the host answers what the game asked, take one
94
+ * tick to apply the answers (and `drawn` after each), until a tick leaves
95
+ * nothing new to apply, `stop()` says play began, `until` passes or
96
+ * `ticks` reach SETTLE_TICKS. `pending` is the engine's count of calls the
97
+ * world has no answer to yet (Engine.platformPending): with nothing in
98
+ * flight at the host, a pending call is one the engine answers itself at
99
+ * the next tick (a slot whose parts all arrived), so that tick is taken
100
+ * too. Returns the ticks taken.
101
+ */
102
+ export async function settle(
103
+ counter: PlatformCounter,
104
+ advance: () => { ok: boolean; reason?: string },
105
+ o: { until: number; ticks?: number; stop?: () => boolean; drawn?: () => void; pending?: () => number },
106
+ ): Promise<number> {
107
+ const most = o.ticks ?? SETTLE_TICKS;
108
+ const due = () => counter.fresh || (counter.waiting === 0 && (o.pending?.() ?? 0) > 0);
109
+ let taken = 0;
110
+ while (taken < most && !o.stop?.()) {
111
+ if (counter.waiting > 0) await counter.quiet(o.until);
112
+ if (o.stop?.() || !due()) break;
113
+ counter.took();
114
+ const r = advance();
115
+ if (!r.ok) throw new Error(r.reason ?? "a tick behind the gate failed");
116
+ taken++;
117
+ o.drawn?.();
118
+ if (!due() && counter.waiting === 0) break;
119
+ if (performance.now() >= o.until) break;
120
+ }
121
+ return taken;
122
+ }