@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
@@ -0,0 +1,1002 @@
1
+ // The engine Worker (docs/design.md §2.2): the wasm instance, the renderer on
2
+ // the OffscreenCanvas the shell transferred, and the game bundle. A game's
3
+ // worker entry is two lines (og build writes it):
4
+ //
5
+ // import { startWorker } from "@origingame/engine/web/worker";
6
+ // import game from "./game.ts";
7
+ // startWorker(game);
8
+ //
9
+ // The shell (shell.ts) sends `start`, then `call`s. With `root`, the Worker
10
+ // fetches the game's cooked bundle before the engine starts (the manifest,
11
+ // then every blob in parallel) and hands it to the wasm; once the renderer is
12
+ // attached it transcodes the Basis textures with the vendored transcoder
13
+ // (basis.ts) to the block format the renderer takes for each kind (BC7/BC5,
14
+ // ASTC 4x4 or ETC2/EAC, else RGBA8), so every load of the bundle completes at
15
+ // once, before the game starts; later files are fetched on request.
16
+ // Probe frames arrive through the request ring (rpc-ring.ts) when the page is
17
+ // cross-origin isolated, else as `rpc` messages; input arrives through the
18
+ // input ring, else as `input` messages. Ticks follow the display frame
19
+ // (requestAnimationFrame in the Worker) unless the shell drives them.
20
+ //
21
+ // Messages to the shell: { type: "progress", value: 0..1, stage, loaded,
22
+ // total } (bytes of the wasm and the cooked bundle actually received, so the
23
+ // value is real and never goes backwards), then { type: "ready", engine,
24
+ // tier, transport, rpc, textureFormat, files, textures } or { type: "error",
25
+ // message } before ready; { type: "frame", first, boot } after the first
26
+ // rendered frame (`boot`: BootPhases, where the Worker's boot went); { type: "crash", message, error } when the engine fails after
27
+ // ready (a trap, a Rust panic on the engine's thread or in a lane, a failing
28
+ // system, an uncaught error; `error` is the structured HostError of shell.ts:
29
+ // kind, message, tick, lane, location); { type: "beat", tick } about once a
30
+ // second while frames play, which the shell's frame deadline watches;
31
+ // { type: "log", lines } with
32
+ // the engine's log and the Worker's console (the crash card shows the last
33
+ // 200); { type: "text", target } when the UI's focused text control changes
34
+ // and { type: "copy", text } when a copy took text (text-input.ts); { type: "a11y", tree } when the UI's
35
+ // accessibility tree changed (a11y.ts); { type: "result", id, value | error } for `call` (`advance`,
36
+ // `command`, `hash`, `frame`, `probe`, and `timings`: the played frames'
37
+ // display intervals and engine work); { type: "rpc",
38
+ // frame } for an answer that did not cross the ring.
39
+ //
40
+ // Requests on the ring are served between frames by the request loop
41
+ // whether or not the clock is playing (a page whose display frames stop, a
42
+ // hidden tab or frame, still answers), and the loop always waits for the
43
+ // next request instead of spinning: a waiting request used to starve the
44
+ // Worker's animation frames while the clock played, so isolated pages never
45
+ // answered.
46
+
47
+ import { createEngine, systemClock, type Engine, type GameToken } from "../runtime.ts";
48
+ import type { PlatformAnswer, PlatformHost } from "../platform-local.ts";
49
+ import { loadWebHost, type WebGlue } from "./host.ts";
50
+ import { drainRing, stampAt, type InjectedInput } from "./input.ts";
51
+ import { WorkerAudio, type AudioLatencyMessage, type AudioMessage, type WebAudioGlue } from "./audio.ts";
52
+ import { readFrames, waitForFrames, writeFrame, type RpcRings } from "./rpc-ring.ts";
53
+ import { loadBasisTranscoder, transcodeKtx2, TEXTURE_FORMATS, type BasisTranscoder, type TextureFormat } from "./basis.ts";
54
+ import type { HostError } from "./shell.ts";
55
+ import { PlatformCounter, settle, SETTLE_FIRST_MS, SETTLE_MS } from "./settle.ts";
56
+
57
+ interface Start {
58
+ type: "start";
59
+ glue: string;
60
+ wasm: string;
61
+ /**
62
+ * The lanes variant (the atomics build, its own glue and module) and the
63
+ * Worker pool's threads, when the shell chose lanes (laneChoice in
64
+ * shell.ts); without it, or when it fails to start, `glue`/`wasm` run
65
+ * single-threaded.
66
+ */
67
+ lanes?: { glue: string; wasm: string; threads: number; bytes?: number };
68
+ /** Why the shell did not choose lanes, reported back in ready. */
69
+ lanesWhy?: string;
70
+ canvas?: OffscreenCanvas;
71
+ webgl?: boolean;
72
+ seed?: number;
73
+ ring?: SharedArrayBuffer;
74
+ rpc?: RpcRings;
75
+ autoplay?: boolean;
76
+ /** The URL base whose `cooked/` bundle the engine opens (`.` next to the page). */
77
+ root?: string;
78
+ /** The Basis transcoder's script and module. */
79
+ basis?: { js: string; wasm: string };
80
+ /** The project's physics profile (project.ogt `profile`). */
81
+ profile?: "2d" | "3d";
82
+ /** project.ogt's text (CreateOptions.project). */
83
+ project?: string;
84
+ /** The locale og.i18n is asked for (the page's `navigator.language`). */
85
+ locale?: string;
86
+ /** The play surface in CSS pixels, its DPR and safe area: what the UI lays out in. */
87
+ viewport?: Viewport;
88
+ /** Bytes the boot will fetch (the wasm, the cooked bundle), for real progress; omitted: counted as they arrive. */
89
+ expect?: { wasm?: number; cooked?: number };
90
+ /**
91
+ * Without autoplay: take the first tick before the first frame, so the frame behind a play gate shows the
92
+ * game's first screen (its UI is built by ticks), then the ticks that apply the platform's answers to it (settle.ts).
93
+ */
94
+ prime?: boolean;
95
+ }
96
+
97
+ type Viewport = { width: number; height: number; dpr: number; safeArea?: Record<string, number>; covered?: Record<string, number>; coarsePointer?: boolean; reducedMotion?: boolean; highContrast?: boolean; reducedTransparency?: boolean; macKeys?: boolean };
98
+
99
+ interface Call {
100
+ type: "call";
101
+ id: number;
102
+ method: string;
103
+ args: unknown[];
104
+ }
105
+
106
+ type Message =
107
+ | Start
108
+ | Call
109
+ | { type: "input"; event: InjectedInput }
110
+ | { type: "play"; on: boolean }
111
+ | { type: "rpc"; frame: string }
112
+ | { type: "viewport"; viewport: Viewport }
113
+ | { type: "resize"; width: number; height: number; viewport: Viewport }
114
+ | { type: "platform-answer"; request: number; answer: PlatformAnswer }
115
+ | { type: "platform-host"; host: string; capabilities: string[] }
116
+ | AudioMessage
117
+ | AudioLatencyMessage;
118
+
119
+ /** og.platform's host in the Worker: the calls go to the page (counted), whose answers come back as `platform-answer`. */
120
+ function toPage(host: string, capabilities: readonly string[], counter: PlatformCounter): PlatformHost {
121
+ return {
122
+ host,
123
+ capabilities,
124
+ handle: (calls) => {
125
+ counter.sent(calls);
126
+ self.postMessage({ type: "platform", calls });
127
+ },
128
+ };
129
+ }
130
+
131
+ interface WorkerScope {
132
+ onmessage: ((e: { data: Message }) => void) | null;
133
+ addEventListener?(type: string, fn: (e: { message?: string; error?: unknown; reason?: unknown; preventDefault?(): void }) => void): void;
134
+ postMessage(message: unknown): void;
135
+ requestAnimationFrame?(fn: (t: number) => void): number;
136
+ }
137
+
138
+ declare const self: WorkerScope;
139
+
140
+ /** The wasm exports the Worker uses beyond the binding symbols. */
141
+ export interface WebExtras {
142
+ og_web_put_file(path: string, bytes: Uint8Array): void;
143
+ og_web_fail_file(path: string, reason: string): void;
144
+ og_web_put_texture(id: string, width: number, height: number, rgba: Uint8Array, srgb: boolean): void;
145
+ og_web_put_texture_encoded(id: string, format: string, width: number, height: number, srgb: boolean, levels: Uint8Array): string;
146
+ og_web_texture_target(engine: number, manifest: string, id: string, bytes: Uint8Array): string;
147
+ og_web_set_fetcher(fetch: (path: string) => void): void;
148
+ og_web_set_inflater?(inflate: (key: number, bytes: Uint8Array) => void): void;
149
+ og_web_put_inflated?(key: number, bytes: Uint8Array): void;
150
+ og_web_fail_inflated?(key: number, reason: string): void;
151
+ og_web_bundle_files(manifest: string): string;
152
+ og_web_texture_ktx2(manifest: string, id: string, bytes: Uint8Array): Uint8Array;
153
+ og_web_pack_ktx2?(engine: number | undefined, manifest: string, pack: Uint8Array): Array<string | Uint8Array>;
154
+ og_web_attach_canvas(engine: number, canvas: OffscreenCanvas, webgl: boolean): Promise<string>;
155
+ og_web_set_viewport(engine: number, viewport: string): string;
156
+ og_web_resize?(engine: number, width: number, height: number): void;
157
+ og_web_lanes?(): string;
158
+ /** What the engine holds (the probe's `memory`, ProbeMemory), as JSON. */
159
+ og_web_memory?(engine: number): string;
160
+ /** Make one pipeline no frame used yet (og-render's Renderer::warm); false when none is left. */
161
+ og_web_warm?(engine: number): boolean;
162
+ /** The first Rust panic recorded (JSON: kind, message, location, tick, lane), or `null`. */
163
+ og_web_panic?(): string;
164
+ /** Test hook: the next item at a lanes site panics (in a lane when the call splits). */
165
+ og_web_lanes_fault?(site: string): boolean;
166
+ }
167
+
168
+ /** Where the runtime asks for a bundle's files: `<root>/cooked/<file>` (crates/og-runtime/src/assets.rs). */
169
+ export function cookedDir(root: string): string {
170
+ return `${root.replace(/\/+$/, "")}/cooked`;
171
+ }
172
+
173
+ /** Bytes received so far by the boot, against what it expects. */
174
+ export class Received {
175
+ loaded = 0;
176
+ total: number;
177
+ constructor(total = 0) {
178
+ this.total = total;
179
+ }
180
+ /** Fraction in [0, 1]; grows the total when more arrived than expected. */
181
+ get value(): number {
182
+ if (this.loaded > this.total) this.total = this.loaded;
183
+ return this.total > 0 ? this.loaded / this.total : 0;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * How long one file's fetch may take, its body included, before it fails:
189
+ * generous, since a pack can be large on a slow network; only a fetch that
190
+ * hangs reaches it.
191
+ */
192
+ export const FETCH_DEADLINE_MS = 300_000;
193
+
194
+ async function bytesOf(url: string, received?: Received, onChunk?: () => void): Promise<Uint8Array> {
195
+ const r = await fetch(url, typeof AbortSignal?.timeout === "function" ? { signal: AbortSignal.timeout(FETCH_DEADLINE_MS) } : {});
196
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
197
+ if (!received || !r.body) {
198
+ const bytes = new Uint8Array(await r.arrayBuffer());
199
+ if (received) received.loaded += bytes.length;
200
+ onChunk?.();
201
+ return bytes;
202
+ }
203
+ const reader = r.body.getReader();
204
+ const chunks: Uint8Array[] = [];
205
+ let length = 0;
206
+ for (;;) {
207
+ const { done, value } = await reader.read();
208
+ if (done) break;
209
+ chunks.push(value);
210
+ length += value.length;
211
+ received.loaded += value.length;
212
+ onChunk?.();
213
+ }
214
+ const out = new Uint8Array(length);
215
+ let at = 0;
216
+ for (const c of chunks) {
217
+ out.set(c, at);
218
+ at += c.length;
219
+ }
220
+ return out;
221
+ }
222
+
223
+ export interface Preloaded {
224
+ readonly files: number;
225
+ readonly failed: number;
226
+ /** Basis textures transcoded and handed to the wasm. */
227
+ readonly textures: number;
228
+ /**
229
+ * The encodings they reached the renderer in, joined by `+` in the order
230
+ * first used (`bc7+bc5` on desktop, `astc4x4` on Apple and most Android,
231
+ * `etc2+eacrg11` elsewhere, `rgba8` without a renderer or compression).
232
+ */
233
+ readonly textureFormat: string;
234
+ }
235
+
236
+ /** A fetched bundle whose Basis textures wait for the renderer's formats. */
237
+ export interface Fetched {
238
+ readonly files: number;
239
+ readonly failed: number;
240
+ readonly manifest: string | null;
241
+ /** Texture blobs by asset id. */
242
+ readonly textures: ReadonlyArray<{ readonly id: string; readonly bytes: Uint8Array }>;
243
+ /** Outputs of streaming groups, left for their packs (fetched when a group loads). */
244
+ readonly streamed: number;
245
+ }
246
+
247
+ /**
248
+ * Fetch the bundle under `root` into the wasm: the manifest and every blob
249
+ * of the first group (everything no streaming group holds, the proxies
250
+ * included). Its textures are transcoded by `transcodeBundle` once the
251
+ * renderer is attached, to the encoding the renderer takes. A streaming
252
+ * group's outputs are not fetched: the engine reads its pack
253
+ * (`cooked/stream/<name>.ogb`) through the fetcher when the group loads;
254
+ * nor are the chunks of a sound cooked in chunks, which the engine reads
255
+ * through the fetcher ahead of the playheads. The engine asks for at most
256
+ * four such files at a time (og-runtime's `stream::fetch`): the chunks a
257
+ * playhead needs soonest first, then the packs by their group's priority.
258
+ */
259
+ export async function preloadBundle(
260
+ glue: WebExtras,
261
+ root: string,
262
+ progress: (value: number, stage: string) => void,
263
+ received?: Received,
264
+ ): Promise<Fetched> {
265
+ const dir = cookedDir(root);
266
+ const manifestPath = `${dir}/.cook.ogt`;
267
+ let manifest: string;
268
+ try {
269
+ manifest = new TextDecoder().decode(await bytesOf(manifestPath));
270
+ } catch (e) {
271
+ glue.og_web_fail_file(manifestPath, `cannot fetch ${manifestPath} (${e instanceof Error ? e.message : String(e)}); run og cook and serve cooked/ next to the page`);
272
+ return { files: 0, failed: 1, manifest: null, textures: [], streamed: 0 };
273
+ }
274
+ glue.og_web_put_file(manifestPath, new TextEncoder().encode(manifest));
275
+ const all = JSON.parse(glue.og_web_bundle_files(manifest)) as Array<{ id: string; file: string; type: string; group?: string }> | { error: string };
276
+ if (!Array.isArray(all)) throw new Error(`${manifestPath}: ${all.error}`);
277
+ // A long sound's chunks (og.SoundChunk) are fetched as its playheads near them.
278
+ const listed = all.filter((o) => o.group === undefined && o.type !== "og.SoundChunk");
279
+ let fetched = 0;
280
+ let failed = 0;
281
+ const blobs = new Map<string, Uint8Array>();
282
+ await Promise.all(
283
+ listed.map(async (o) => {
284
+ const path = `${dir}/${o.file}`;
285
+ try {
286
+ const bytes = await bytesOf(path, received, received ? () => progress(received.value, "fetch") : undefined);
287
+ glue.og_web_put_file(path, bytes);
288
+ blobs.set(o.id, bytes);
289
+ } catch (e) {
290
+ failed++;
291
+ glue.og_web_fail_file(path, `cannot fetch ${path} (${e instanceof Error ? e.message : String(e)})`);
292
+ }
293
+ ++fetched;
294
+ if (!received) progress(0.1 + 0.7 * (fetched / Math.max(1, listed.length)), "fetch");
295
+ }),
296
+ );
297
+ const textures = listed.filter((o) => o.type === "og.Texture" && blobs.has(o.id)).map((o) => ({ id: o.id, bytes: blobs.get(o.id)! }));
298
+ return { files: listed.length - failed, failed, manifest, textures, streamed: all.length - listed.length };
299
+ }
300
+
301
+ /**
302
+ * Transcode a fetched bundle's Basis textures for `engine`'s renderer
303
+ * (`null`: none attached, RGBA8): each to the encoding the wasm names for
304
+ * its kind on this device (`og_web_texture_target`: BC7 and BC5 on desktop
305
+ * GPUs, ASTC 4x4 on Apple and most Android, ETC2 and EAC RG11 elsewhere,
306
+ * RGBA8 last), every mip level handed over; RGBA8 hands level 0 and the GPU
307
+ * builds the mips.
308
+ */
309
+ export async function transcodeBundle(
310
+ glue: WebExtras,
311
+ engine: number | null,
312
+ fetched: Fetched,
313
+ basis: { js: string; wasm: string } | undefined,
314
+ progress: (value: number, stage: string) => void,
315
+ received?: Received,
316
+ ): Promise<Preloaded> {
317
+ const used: string[] = [];
318
+ let transcoder: BasisTranscoder | null = null;
319
+ let transcoded = 0;
320
+ const manifest = fetched.manifest;
321
+ for (const [i, t] of fetched.textures.entries()) {
322
+ const ktx2 = manifest === null ? new Uint8Array() : glue.og_web_texture_ktx2(manifest, t.id, t.bytes);
323
+ if (ktx2.length > 0) {
324
+ transcoder ??= basis ? await loadBasisTranscoder(basis.js, basis.wasm) : await loadBasisTranscoder();
325
+ const target = engine === null ? "rgba8" : glue.og_web_texture_target(engine, manifest!, t.id, t.bytes);
326
+ const out = transcodeKtx2(transcoder, ktx2, isTextureFormat(target) ? target : "rgba8");
327
+ if (out.format === "rgba8") {
328
+ glue.og_web_put_texture(t.id, out.width, out.height, out.levels[0]!, out.srgb);
329
+ } else {
330
+ const refused = glue.og_web_put_texture_encoded(t.id, out.format, out.width, out.height, out.srgb, concat(out.levels));
331
+ if (refused) throw new Error(refused);
332
+ }
333
+ if (!used.includes(out.format)) used.push(out.format);
334
+ transcoded++;
335
+ }
336
+ if (!received) progress(0.8 + 0.2 * ((i + 1) / fetched.textures.length), "transcode");
337
+ }
338
+ return { files: fetched.files, failed: fetched.failed, textures: transcoded, textureFormat: used.length ? used.join("+") : "rgba8" };
339
+ }
340
+
341
+ function isTextureFormat(name: string): name is TextureFormat {
342
+ return (TEXTURE_FORMATS as readonly string[]).includes(name);
343
+ }
344
+
345
+ function concat(parts: readonly Uint8Array[]): Uint8Array {
346
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
347
+ let at = 0;
348
+ for (const p of parts) {
349
+ out.set(p, at);
350
+ at += p.length;
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * What the engine's fetcher does with a file it asks for after the boot: a
357
+ * streaming group's pack has its Basis textures transcoded, to the encoding
358
+ * `engine()`'s renderer takes for each (RGBA8 without one), and handed over
359
+ * first, so that every texture in it is ready when the engine reads the
360
+ * pack; any other file is handed over as fetched.
361
+ */
362
+ export function installFetcher(glue: WebExtras, manifest: string, basis: { js: string; wasm: string } | undefined, engine: () => number | null): void {
363
+ let transcoder: Promise<BasisTranscoder> | null = null;
364
+ const put = async (path: string, bytes: Uint8Array) => {
365
+ const triples = /\/stream\/[^/]+\.ogb$/.test(path) && glue.og_web_pack_ktx2 ? glue.og_web_pack_ktx2(engine() ?? undefined, manifest, bytes) : [];
366
+ for (let i = 0; i + 2 < triples.length; i += 3) {
367
+ let loading = transcoder;
368
+ if (!loading) {
369
+ const started = basis ? loadBasisTranscoder(basis.js, basis.wasm) : loadBasisTranscoder();
370
+ transcoder = loading = started;
371
+ // A load that failed is not kept: the next pack tries again.
372
+ started.catch(() => {
373
+ if (transcoder === started) transcoder = null;
374
+ });
375
+ }
376
+ const id = triples[i] as string;
377
+ const target = triples[i + 2] as string;
378
+ const out = transcodeKtx2(await loading, triples[i + 1] as Uint8Array, isTextureFormat(target) ? target : "rgba8");
379
+ if (out.format === "rgba8") {
380
+ glue.og_web_put_texture(id, out.width, out.height, out.levels[0]!, out.srgb);
381
+ } else {
382
+ const refused = glue.og_web_put_texture_encoded(id, out.format, out.width, out.height, out.srgb, concat(out.levels));
383
+ if (refused) throw new Error(refused);
384
+ }
385
+ }
386
+ glue.og_web_put_file(path, bytes);
387
+ };
388
+ glue.og_web_set_fetcher((path: string) => {
389
+ bytesOf(path)
390
+ .then((bytes) => put(path, bytes))
391
+ .catch((e: unknown) => glue.og_web_fail_file(path, `cannot fetch ${path} (${e instanceof Error ? e.message : String(e)})`));
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Gives the engine the browser's DecompressionStream for zlib bytes (terrain
397
+ * tiles): it inflates natively off the engine's work and answers by key; an
398
+ * engine without it inflates in wasm within a frame budget.
399
+ */
400
+ export function installInflater(glue: Pick<WebExtras, "og_web_set_inflater" | "og_web_put_inflated" | "og_web_fail_inflated">): boolean {
401
+ const Stream = (globalThis as { DecompressionStream?: new (format: string) => TransformStream<Uint8Array, Uint8Array> }).DecompressionStream;
402
+ const { og_web_set_inflater: set, og_web_put_inflated: put, og_web_fail_inflated: fail } = glue;
403
+ if (!Stream || !set || !put || !fail) return false;
404
+ set((key: number, bytes: Uint8Array) => {
405
+ new Response(new Blob([new Uint8Array(bytes)]).stream().pipeThrough(new Stream("deflate")))
406
+ .arrayBuffer()
407
+ .then(
408
+ (b) => put(key, new Uint8Array(b)),
409
+ (e: unknown) => fail(key, e instanceof Error ? e.message : String(e)),
410
+ );
411
+ });
412
+ return true;
413
+ }
414
+
415
+ /**
416
+ * A `capture` or `sweep` answered null: on the web the readback finishes on
417
+ * a later call, and a sweep goes on from the frames it has each time it is
418
+ * asked again. (A sweep refused outright, an unknown entity, answers null
419
+ * too: it is asked again until its budget runs out.)
420
+ */
421
+ function isPendingCapture(frame: string, answer: string): boolean {
422
+ try {
423
+ const request = JSON.parse(frame) as { method?: string };
424
+ if (request.method !== "capture" && request.method !== "sweep") return false;
425
+ const response = JSON.parse(answer) as { result?: unknown; error?: unknown };
426
+ return response.error === undefined && response.result === null;
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+
432
+ /** Display frames a pending `capture` is asked again on before it is given up on. */
433
+ const CAPTURE_RETRIES = 60;
434
+
435
+ /** Frames a `sweep` may wait: each value needs at least one call, at most 64 values. */
436
+ function retriesOf(frame: string): number {
437
+ try {
438
+ const request = JSON.parse(frame) as { method?: string; params?: { values?: unknown[] } };
439
+ if (request.method === "sweep") return 2 * (request.params?.values?.length ?? 0) + CAPTURE_RETRIES;
440
+ } catch {
441
+ /* not a sweep */
442
+ }
443
+ return CAPTURE_RETRIES;
444
+ }
445
+
446
+ /** The most log lines the Worker keeps for a crash card. */
447
+ export const LOG_LINES = 200;
448
+
449
+ /** Played frames whose timings the Worker keeps (`timings`). */
450
+ const FRAME_RECORDS = 4096;
451
+
452
+ /** How often, at most, a playing Worker tells the shell it is alive (`beat`), ms. */
453
+ export const BEAT_MS = 1000;
454
+
455
+ /**
456
+ * What a failure was: a Rust panic the wasm recorded before it trapped
457
+ * (`og_web_panic`: its message, location, tick and lane), or else `message`
458
+ * at the last tick a frame reached.
459
+ */
460
+ export function hostErrorOf(panic: string | undefined, message: string, tick: number | null): HostError {
461
+ try {
462
+ const p = JSON.parse(panic ?? "null") as { kind?: string; message?: string; location?: string; tick?: number; lane?: string | null } | null;
463
+ if (p && p.kind === "panic") {
464
+ return { kind: "panic", message: p.message ?? message, tick: typeof p.tick === "number" ? p.tick : tick, lane: p.lane ?? null, location: p.location || null };
465
+ }
466
+ } catch {
467
+ /* not JSON: no panic report */
468
+ }
469
+ return { kind: "error", message, tick, lane: null, location: null };
470
+ }
471
+
472
+ /** One line for the crash card and the log: `panic at tick 41 in a lane (cull): … (file:line:col)`. */
473
+ export function describeHostError(e: HostError): string {
474
+ if (e.kind !== "panic") return e.message;
475
+ return `panic at tick ${e.tick ?? "?"}${e.lane ? ` in a lane (${e.lane})` : ""}: ${e.message}${e.location ? ` (${e.location})` : ""}`;
476
+ }
477
+
478
+ /**
479
+ * Where the boot went, ms on the Worker's clock: each phase's duration, in
480
+ * order (`wasm`: the engine module fetched and instantiated, the lanes
481
+ * pool started; `bundle`: the first group fetched; `create`: the engine and
482
+ * the game's declarations; `attach`: the renderer created on the canvas;
483
+ * `transcode`: the Basis textures; `prime`: the tick behind the gate;
484
+ * `settle`: the ticks that applied the platform's answers behind the gate;
485
+ * `present`: the first frame rendered, its pipelines compiled), and `at`,
486
+ * the Worker's time at the first frame from its own start.
487
+ */
488
+ export type BootPhases = Partial<Record<"wasm" | "bundle" | "create" | "attach" | "transcode" | "prime" | "settle" | "present", number>> & { at?: number };
489
+
490
+ /** Run `game` in this Worker, driven by the shell's messages. */
491
+ export function startWorker(game: GameToken): void {
492
+ let engine: Engine | null = null;
493
+ let ring: SharedArrayBuffer | undefined;
494
+ let rpc: RpcRings | undefined;
495
+ let canvas: OffscreenCanvas | undefined;
496
+ let rendering = false;
497
+ let playing = false;
498
+ let scheduled = false;
499
+ let ready = false;
500
+ let crashed = false;
501
+ let firstFrame = false;
502
+ let last = 0;
503
+ /** The tick the last frame reached (a crash's tick when no panic names one). */
504
+ let lastTick: number | null = null;
505
+ let lastBeat = 0;
506
+ /** What the page was asked and answered: the ticks behind the gate apply its answers (settle.ts). */
507
+ const platformCounter = new PlatformCounter();
508
+ /** Where the boot went (BootPhases), sent with the first frame. */
509
+ const boot: BootPhases = {};
510
+ let phaseAt = performance.now();
511
+ const phase = (name: Exclude<keyof BootPhases, "at">) => {
512
+ const now = performance.now();
513
+ boot[name] = Math.round((boot[name] ?? 0) + now - phaseAt);
514
+ phaseAt = now;
515
+ };
516
+ const firstFrameMessage = () => ({ type: "frame", first: true, boot: { ...boot, at: Math.round(performance.now()) } });
517
+ let glueRef: (WebGlue & WebExtras & WebAudioGlue) | null = null;
518
+ let audio: WorkerAudio | null = null;
519
+ /** Captures waiting for their readback (requested on tick N, answered on N + 1). */
520
+ const retries: Array<{ frame: string; left: number; viaRing: boolean }> = [];
521
+
522
+ // The console and the engine's log reach the shell (the crash card's last lines).
523
+ const log = (text: string) => self.postMessage({ type: "log", lines: [text] });
524
+ const consoleRef = (globalThis as unknown as { console?: Record<string, (...a: unknown[]) => void> }).console;
525
+ if (consoleRef) {
526
+ for (const level of ["error", "warn", "log", "info"] as const) {
527
+ const original = consoleRef[level]?.bind(consoleRef);
528
+ consoleRef[level] = (...args: unknown[]) => {
529
+ original?.(...args);
530
+ try {
531
+ log(`${level === "log" || level === "info" ? "" : `${level}: `}${args.map((a) => (a instanceof Error ? (a.stack ?? a.message) : typeof a === "string" ? a : JSON.stringify(a))).join(" ")}`);
532
+ } catch {
533
+ /* an unserializable argument is not worth a crash */
534
+ }
535
+ };
536
+ }
537
+ }
538
+ const flushLog = () => {
539
+ if (!engine) return;
540
+ const lines = engine.takeLog();
541
+ if (lines.length > 0) self.postMessage({ type: "log", lines: lines.map((l) => `tick ${l.tick}: ${l.warning ? "warning: " : ""}${l.text}`) });
542
+ };
543
+ const crash = (message: string) => {
544
+ if (crashed) return;
545
+ crashed = true;
546
+ playing = false;
547
+ let panic: string | undefined;
548
+ try {
549
+ // A trap after a Rust panic: the panic hook recorded what it said, where and when.
550
+ panic = glueRef?.og_web_panic?.();
551
+ } catch {
552
+ /* the module is past answering */
553
+ }
554
+ const error = hostErrorOf(panic, message, lastTick);
555
+ try {
556
+ flushLog();
557
+ } catch {
558
+ /* the engine may be the thing that broke */
559
+ }
560
+ const text = describeHostError(error);
561
+ self.postMessage(ready ? { type: "crash", message: text, error } : { type: "error", message: text, error });
562
+ };
563
+ self.addEventListener?.("error", (e) => crash(e.message ?? String(e.error)));
564
+ self.addEventListener?.("unhandledrejection", (e) => crash(e.reason instanceof Error ? e.reason.message : String(e.reason)));
565
+
566
+ /** Real time not yet simulated after the last frame (its answer's `behind`), ms. */
567
+ let behind = 0;
568
+ /** The text target last sent to the shell, as JSON. */
569
+ let textTarget = "null";
570
+ /**
571
+ * The played frames' timings, the last FRAME_RECORDS of them (the shell's
572
+ * `timings` call, which `og run --device` reads): the display interval
573
+ * (this frame's timestamp minus the last one's) and the engine's work
574
+ * (`engine.frame`: input, ticks, render submission), ms.
575
+ */
576
+ const intervals = new Float32Array(FRAME_RECORDS);
577
+ const work = new Float32Array(FRAME_RECORDS);
578
+ let recorded = 0;
579
+ /**
580
+ * An event with the page's time becomes one with its `at` within the tick
581
+ * that reads it (input.ts stampAt): `now` is this Worker's clock now (a
582
+ * frame's timestamp, or a message's arrival), on the same timeline as `last`.
583
+ */
584
+ const stamped = (event: InjectedInput, now: number): InjectedInput => {
585
+ if (!engine || !("time" in event) || event.time === undefined) return event;
586
+ const { time, ...rest } = event;
587
+ const ahead = behind + (last ? Math.max(0, now - last) : 0);
588
+ return { ...rest, at: stampAt(time, performance.timeOrigin + now, ahead, 1000 / engine.rate) } as InjectedInput;
589
+ };
590
+ const pumpInput = (now: number) => {
591
+ if (engine && ring) for (const event of drainRing(ring)) engine.probe.input(stamped(event, now));
592
+ };
593
+
594
+ /** What a frame's answer tells the shell: the UI's focused text control (text-input.ts), a copy, and the accessibility tree when it changed (a11y.ts). */
595
+ const relay = (answered: ReturnType<Engine["frame"]>) => {
596
+ if (!answered.ok) return;
597
+ const target = JSON.stringify(answered.text ?? null);
598
+ if (target !== textTarget) {
599
+ textTarget = target;
600
+ self.postMessage({ type: "text", target: answered.text ?? null });
601
+ }
602
+ if (answered.copy !== undefined) self.postMessage({ type: "copy", text: answered.copy });
603
+ if (answered.a11y !== undefined) self.postMessage({ type: "a11y", tree: answered.a11y });
604
+ };
605
+
606
+ const answer = (frame: string, viaRing: boolean) => {
607
+ if (!viaRing || !rpc || !writeFrame(rpc.answers, frame)) self.postMessage({ type: "rpc", frame });
608
+ };
609
+
610
+ /** A JSON-RPC error answer to a request frame (its id, or null when it has none). */
611
+ const refusal = (frame: string, message: string) => {
612
+ const id = (() => {
613
+ try {
614
+ return (JSON.parse(frame) as { id?: unknown }).id ?? null;
615
+ } catch {
616
+ return null;
617
+ }
618
+ })();
619
+ return JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32603, message } });
620
+ };
621
+
622
+ /** One request frame: answered now, or kept for a retry when it is a capture whose readback has not landed. */
623
+ const serve = (frame: string, viaRing: boolean) => {
624
+ if (!engine) return answer(refusal(frame, "the engine has not started"), viaRing);
625
+ let out: string;
626
+ try {
627
+ out = engine.call(frame);
628
+ } catch (e) {
629
+ answer(refusal(frame, e instanceof Error ? e.message : String(e)), viaRing);
630
+ crash(e instanceof Error ? e.message : String(e));
631
+ return;
632
+ }
633
+ if (rendering && isPendingCapture(frame, out)) {
634
+ retries.push({ frame, left: retriesOf(frame), viaRing });
635
+ schedule();
636
+ } else answer(out, viaRing);
637
+ };
638
+
639
+ const retry = () => {
640
+ if (!engine) return;
641
+ for (let i = retries.length - 1; i >= 0; i--) {
642
+ const r = retries[i]!;
643
+ const out = engine.call(r.frame);
644
+ r.left--;
645
+ if (!isPendingCapture(r.frame, out) || r.left <= 0) {
646
+ retries.splice(i, 1);
647
+ answer(out, r.viaRing);
648
+ }
649
+ }
650
+ };
651
+
652
+ const drainRpc = () => {
653
+ if (engine && rpc) for (const frame of readFrames(rpc.requests)) serve(frame, true);
654
+ };
655
+
656
+ const frame = (now: number) => {
657
+ scheduled = false;
658
+ if (!engine || crashed) return;
659
+ try {
660
+ pumpInput(now);
661
+ // Retries first: a capture asked this frame is requested now and answered on a later frame.
662
+ retry();
663
+ drainRpc();
664
+ // The clock holds while a capture waits for its readback.
665
+ if (playing && retries.length === 0) {
666
+ const began = performance.now();
667
+ const answered = engine.frame(last ? now - last : 0);
668
+ if (!answered.ok) {
669
+ crash(answered.reason ?? "the engine refused a frame");
670
+ return;
671
+ }
672
+ if (last) {
673
+ intervals[recorded % FRAME_RECORDS] = now - last;
674
+ work[recorded % FRAME_RECORDS] = performance.now() - began;
675
+ recorded++;
676
+ }
677
+ behind = answered.behind ?? 0;
678
+ relay(answered);
679
+ if (typeof answered.tick === "number") lastTick = answered.tick;
680
+ if (!firstFrame && answered.rendered !== false) {
681
+ firstFrame = true;
682
+ self.postMessage(firstFrameMessage());
683
+ }
684
+ audio?.pump();
685
+ }
686
+ flushLog();
687
+ } catch (e) {
688
+ crash(e instanceof Error ? (e.stack ?? e.message) : String(e));
689
+ return;
690
+ }
691
+ // Alive: the shell's frame deadline hears from a playing Worker about once a second.
692
+ if (playing && now - lastBeat >= BEAT_MS) {
693
+ lastBeat = now;
694
+ self.postMessage({ type: "beat", tick: lastTick });
695
+ }
696
+ last = now;
697
+ if (playing || retries.length > 0) schedule();
698
+ };
699
+ /**
700
+ * The next frame on the display (requestAnimationFrame in the Worker); a
701
+ * display that stops sending frames (a hidden page) gets a timer instead,
702
+ * so a capture still lands.
703
+ */
704
+ const schedule = () => {
705
+ if (scheduled) return;
706
+ scheduled = true;
707
+ let ran = false;
708
+ const run = (t: number) => {
709
+ if (ran) return;
710
+ ran = true;
711
+ frame(t);
712
+ };
713
+ if (self.requestAnimationFrame) self.requestAnimationFrame(run);
714
+ if (!playing || !self.requestAnimationFrame) setTimeout(() => run(performance.now()), self.requestAnimationFrame ? 100 : 16);
715
+ };
716
+
717
+ /** Requests are answered between frames, whether the clock plays or not. */
718
+ const requests = async () => {
719
+ while (rpc && !crashed) {
720
+ await waitForFrames(rpc.requests, 1000);
721
+ if (!engine) continue;
722
+ try {
723
+ pumpInput(performance.now());
724
+ drainRpc();
725
+ } catch (e) {
726
+ crash(e instanceof Error ? e.message : String(e));
727
+ }
728
+ // Yield to the event loop: frames, fetches and messages run between requests.
729
+ await new Promise((r) => setTimeout(r, 0));
730
+ }
731
+ };
732
+
733
+ /**
734
+ * Behind the gate, in idle time: the renderer makes the pipelines the first frame did not
735
+ * draw with, one a task (og-render deferred.rs; WebGL2 compiles each synchronously), until
736
+ * none is left or play begins (a pipeline still unmade is made by the frame that first draws with it).
737
+ */
738
+ const warmBehindGate = () => {
739
+ const warm = glueRef?.og_web_warm;
740
+ if (!warm || !rendering) return;
741
+ const began = performance.now();
742
+ let made = 0;
743
+ const step = () => {
744
+ if (playing || crashed || !engine) return;
745
+ let more: boolean;
746
+ try {
747
+ more = warm(engine.id);
748
+ } catch (e) {
749
+ crash(e instanceof Error ? e.message : String(e));
750
+ return;
751
+ }
752
+ if (more) {
753
+ made++;
754
+ setTimeout(step, 0);
755
+ } else if (made > 0) console.info(`renderer: ${made} pipelines made behind the gate in ${Math.round(performance.now() - began)} ms`);
756
+ };
757
+ setTimeout(step, 0);
758
+ };
759
+ /** While the first frame waits for the platform's first answers (settle.ts): a resize then draws nothing yet. */
760
+ let settling = false;
761
+ /** One rendered frame without a tick: the first frame behind the play gate. */
762
+ const present = () => {
763
+ if (!engine || playing || settling) return;
764
+ try {
765
+ const answered = engine.frame(0);
766
+ if (!answered.ok) return crash(answered.reason ?? "the engine refused a frame");
767
+ relay(answered);
768
+ if (!firstFrame && answered.rendered !== false) {
769
+ firstFrame = true;
770
+ phase("present");
771
+ self.postMessage(firstFrameMessage());
772
+ }
773
+ flushLog();
774
+ } catch (e) {
775
+ crash(e instanceof Error ? e.message : String(e));
776
+ }
777
+ };
778
+
779
+ const calls: Record<string, (e: Engine, args: unknown[]) => unknown> = {
780
+ advance: (e, [n]) => e.advance(Number(n)),
781
+ command: (e, [action, args]) => e.command(String(action), (args as string[]) ?? []),
782
+ hash: (e) => e.hash(),
783
+ /** What the engine holds (ProbeMemory: the wasm linear memory, the counted heap, the world, streaming, audio), in every build. */
784
+ memory: (e) => JSON.parse(glueRef?.og_web_memory?.(e.id) ?? "null"),
785
+ /** What the lanes did so far (crates/og-runtime/src/lanes.rs `report`). */
786
+ lanes: () => JSON.parse(glueRef?.og_web_lanes?.() ?? "null"),
787
+ /** Test hook: the next item at lanes site `site` panics (og_web_lanes_fault); false when the module has no such hook or site. */
788
+ fault: (_, [site]) => glueRef?.og_web_lanes_fault?.(String(site)) ?? false,
789
+ frame: (e, [ms]) => {
790
+ const answered = e.frame(Number(ms));
791
+ relay(answered);
792
+ return answered;
793
+ },
794
+ // The frames played since the last reset (at most FRAME_RECORDS, oldest first); `true` resets.
795
+ timings: (_, [reset]) => {
796
+ const n = Math.min(recorded, FRAME_RECORDS);
797
+ const at = (a: Float32Array) => Array.from({ length: n }, (_, i) => Math.round(a[(recorded - n + i) % FRAME_RECORDS]! * 1000) / 1000);
798
+ const answer = { frames: recorded, intervals: at(intervals), work: at(work) };
799
+ if (reset) recorded = 0;
800
+ return answer;
801
+ },
802
+ probe: (e, [member, ...rest]) => {
803
+ const f = (e.probe as unknown as Record<string, unknown>)[String(member)];
804
+ if (typeof f !== "function") throw new TypeError(`probe has no member '${String(member)}'`);
805
+ return (f as (...a: unknown[]) => unknown)(...rest);
806
+ },
807
+ };
808
+
809
+ self.onmessage = async ({ data }) => {
810
+ if (data.type === "start") {
811
+ try {
812
+ const received = new Received((data.expect?.wasm ?? 0) + (data.expect?.cooked ?? 0));
813
+ const progress = (value: number, stage: string) =>
814
+ self.postMessage({ type: "progress", value: Math.min(1, value), stage, loaded: received.loaded, total: received.total });
815
+ progress(0, "wasm");
816
+ phaseAt = performance.now();
817
+ // The wasm streams through a counting body, so its bytes move the bar while it compiles.
818
+ const counted = async (url: string, expected: number | undefined): Promise<unknown> => {
819
+ if (!expected || typeof Response !== "function" || typeof ReadableStream !== "function") return url;
820
+ const r = await fetch(url);
821
+ if (!r.ok || !r.body) throw new Error(`cannot fetch ${url} (HTTP ${r.status})`);
822
+ const reader = r.body.getReader();
823
+ const body = new ReadableStream<Uint8Array>({
824
+ async pull(controller) {
825
+ const { done, value } = await reader.read();
826
+ if (done) return controller.close();
827
+ received.loaded += value.length;
828
+ progress(received.value, "wasm");
829
+ controller.enqueue(value);
830
+ },
831
+ });
832
+ return new Response(body, { headers: { "content-type": "application/wasm" } });
833
+ };
834
+ // Lanes: the atomics build on a pool of Workers sharing its memory. A
835
+ // variant that does not load or whose pool does not start falls back
836
+ // to the single-threaded build, the same engine with the same API.
837
+ let lanes: { on: boolean; threads: number; why: string } = { on: false, threads: 1, why: data.lanesWhy ?? "the page offered no lanes variant" };
838
+ let glue: WebGlue & WebExtras & WebAudioGlue | null = null;
839
+ if (data.lanes) {
840
+ // The bar's total counts the lanes wasm in place of the
841
+ // single-threaded one; after a failure it counts what the lanes
842
+ // wasm downloaded plus the single-threaded wasm, once on every path.
843
+ const total = received.total;
844
+ const loaded = received.loaded;
845
+ const lanesBytes = data.lanes.bytes ?? data.expect?.wasm;
846
+ try {
847
+ const g = (await import(data.lanes.glue)) as WebGlue & WebExtras & WebAudioGlue;
848
+ if (data.expect) received.total = total + (lanesBytes ?? 0) - (data.expect.wasm ?? 0);
849
+ await loadWebHost(g, await counted(data.lanes.wasm, lanesBytes), data.lanes.threads);
850
+ glue = g;
851
+ lanes = { on: true, threads: data.lanes.threads, why: "" };
852
+ } catch (e) {
853
+ // The lanes glue is discarded: loadWebHost bound nothing and told the engine nothing.
854
+ if (data.expect) received.total = total + (received.loaded - loaded);
855
+ lanes = { on: false, threads: 1, why: `the lanes variant did not start (${e instanceof Error ? e.message : String(e)}); single-threaded` };
856
+ }
857
+ }
858
+ if (!glue) {
859
+ glue = (await import(data.glue)) as WebGlue & WebExtras & WebAudioGlue;
860
+ await loadWebHost(glue, await counted(data.wasm, data.expect?.wasm));
861
+ }
862
+ glueRef = glue;
863
+ phase("wasm");
864
+ // Terrain tiles are inflated by the browser off the engine's thread where it can.
865
+ installInflater(glue);
866
+ progress(data.expect ? received.value : 0.1, "bundle");
867
+ let fetched: Fetched | null = null;
868
+ if (data.root !== undefined) {
869
+ fetched = await preloadBundle(glue, data.root, progress, data.expect ? received : undefined);
870
+ installFetcher(glue, fetched.manifest ?? "", data.basis, () => (rendering && engine ? engine.id : null));
871
+ }
872
+ phase("bundle");
873
+ const created = createEngine(game, {
874
+ ...(data.seed === undefined ? {} : { seed: data.seed }),
875
+ ...(data.root === undefined ? {} : { root: data.root }),
876
+ ...(data.profile === undefined ? {} : { profile: data.profile }),
877
+ ...(data.project === undefined ? {} : { project: data.project }),
878
+ ...(data.locale ? { locale: data.locale } : {}),
879
+ deferStart: true,
880
+ a11y: true,
881
+ });
882
+ if (!created.ok) throw new Error(created.reason);
883
+ engine = created.value;
884
+ // og.platform: calls go to the page, which answers them (the Player, or the browser's storage).
885
+ // The time is the browser's until the Player's answers carry its own (docs/platform-asks.md).
886
+ engine.setClock(systemClock("browser"));
887
+ engine.attachPlatform(toPage("none", [], platformCounter));
888
+ if (data.viewport) glue.og_web_set_viewport(engine.id, JSON.stringify(data.viewport));
889
+ ring = data.ring;
890
+ rpc = data.rpc;
891
+ phase("create");
892
+ let tier = "none";
893
+ if (data.canvas) {
894
+ canvas = data.canvas;
895
+ tier = await glue.og_web_attach_canvas(engine.id, data.canvas, !!data.webgl);
896
+ rendering = true;
897
+ }
898
+ phase("attach");
899
+ // The textures go to the renderer's encodings, so after it is attached (the game starts later).
900
+ const loaded = fetched ? await transcodeBundle(glue, rendering ? engine.id : null, fetched, data.basis, progress, data.expect ? received : undefined) : null;
901
+ phase("transcode");
902
+ progress(1, "ready");
903
+ ready = true;
904
+ self.postMessage({
905
+ type: "ready",
906
+ engine: engine.id,
907
+ tier,
908
+ transport: ring ? "ring" : "message",
909
+ rpc: rpc ? "ring" : "message",
910
+ textureFormat: loaded?.textureFormat ?? null,
911
+ files: loaded?.files ?? 0,
912
+ textures: loaded?.textures ?? 0,
913
+ lanes,
914
+ });
915
+ void requests();
916
+ if (data.autoplay) {
917
+ playing = true;
918
+ schedule();
919
+ } else if (rendering) {
920
+ let primed = false;
921
+ if (data.prime && engine.probe.tick() === 0) {
922
+ const first = engine.advance(1);
923
+ if (!first.ok) throw new Error(first.reason);
924
+ primed = true;
925
+ }
926
+ phase("prime");
927
+ // The platform's answers to the first tick's reads (the saved language, a slot's
928
+ // meta) applied behind the gate: before the first frame while they come quickly,
929
+ // then each settling tick drawn as it lands (settle.ts).
930
+ const at = performance.now();
931
+ const behindGate = {
932
+ stop: () => playing || crashed,
933
+ pending: () => engine?.platformPending() ?? 0,
934
+ advance: () => (engine ? engine.advance(1) : { ok: false, reason: "no engine" }),
935
+ };
936
+ if (primed) {
937
+ settling = true;
938
+ try {
939
+ await settle(platformCounter, behindGate.advance, { until: at + SETTLE_FIRST_MS, stop: behindGate.stop, pending: behindGate.pending });
940
+ } finally {
941
+ settling = false;
942
+ }
943
+ }
944
+ phase("settle");
945
+ present();
946
+ warmBehindGate();
947
+ if (primed && (platformCounter.waiting > 0 || platformCounter.fresh || behindGate.pending() > 0)) {
948
+ void settle(platformCounter, behindGate.advance, { until: at + SETTLE_MS, stop: behindGate.stop, pending: behindGate.pending, drawn: present }).catch((e: unknown) => crash(e instanceof Error ? e.message : String(e)));
949
+ }
950
+ }
951
+ } catch (e) {
952
+ crash(e instanceof Error ? e.message : String(e));
953
+ }
954
+ return;
955
+ }
956
+ if (!engine || crashed) {
957
+ // A request is answered even now, so that nothing on the page waits for an answer that never comes.
958
+ const why = crashed ? "the engine stopped" : "the engine has not started";
959
+ if (data.type === "call") self.postMessage({ type: "result", id: data.id, error: why });
960
+ if (data.type === "rpc") answer(refusal(data.frame, why), false);
961
+ return;
962
+ }
963
+ if (data.type === "input") engine.probe.input(stamped(data.event, performance.now()));
964
+ if (data.type === "viewport" && glueRef) glueRef.og_web_set_viewport(engine.id, JSON.stringify(data.viewport));
965
+ if (data.type === "resize" && glueRef) {
966
+ if (canvas) {
967
+ canvas.width = Math.max(1, data.width);
968
+ canvas.height = Math.max(1, data.height);
969
+ }
970
+ glueRef.og_web_resize?.(engine.id, data.width, data.height);
971
+ glueRef.og_web_set_viewport(engine.id, JSON.stringify(data.viewport));
972
+ if (!playing) present();
973
+ }
974
+ if (data.type === "audio-latency") audio?.latency(data.latency);
975
+ if (data.type === "audio" && glueRef && !audio) {
976
+ const attached = WorkerAudio.attach(glueRef, engine.id, data);
977
+ if (attached.ok) audio = attached.value;
978
+ else log(`warn: audio: ${attached.reason}`);
979
+ }
980
+ if (data.type === "rpc") serve(data.frame, false);
981
+ if (data.type === "platform-answer") {
982
+ engine.answerPlatform(data.request, data.answer);
983
+ platformCounter.answered();
984
+ }
985
+ if (data.type === "platform-host") engine.attachPlatform(toPage(data.host, data.capabilities, platformCounter));
986
+ if (data.type === "play") {
987
+ playing = data.on;
988
+ last = 0;
989
+ if (playing) schedule();
990
+ }
991
+ if (data.type === "call") {
992
+ try {
993
+ pumpInput(performance.now());
994
+ const call = calls[data.method];
995
+ if (!call) throw new Error(`unknown call '${data.method}'`);
996
+ self.postMessage({ type: "result", id: data.id, value: call(engine, data.args) });
997
+ } catch (e) {
998
+ self.postMessage({ type: "result", id: data.id, error: e instanceof Error ? e.message : String(e) });
999
+ }
1000
+ }
1001
+ };
1002
+ }