@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,373 @@
1
+ // `og run` on the native host: load a game bundle under bun (or node) with
2
+ // the napi addon, open the project's cooked bundle, mount the probe, and
3
+ // drive the tick.
4
+ //
5
+ // bun src/node/run.ts --entry <game.js|.ts> [--root <project>] [--ticks N] [--seed S]
6
+ // [--setup action[:arg...]]... [--commands "t:action[:args];..."] [--out <dir>]
7
+ // [--port P] [--size WxH] [--realtime] [--watch <bundle.js>] [--no-render]
8
+ // [--audio null|device|<out.wav>] [--platform-store <file.json>] [--clock <UTC ms>] [--locale <tag>] [--json]
9
+ //
10
+ // - Without --commands, --port or --watch: advance --ticks N at once and print
11
+ // the world hash (M2's hello world).
12
+ // - With --commands: the script is scheduled through og-probe at tick 0 and
13
+ // the run takes one tick per frame until `quit`, --ticks, or the script's
14
+ // last step has run. Outputs (`screenshot`, `dump_*`, `help`) are written
15
+ // under --out at the probe's `Output::file_name()`.
16
+ // - With --port: the probe answers JSON-RPC 2.0 at ws://127.0.0.1:<port>/probe
17
+ // between frames (`og probe`); the run keeps going until `quit` or a signal.
18
+ // - With --watch: a change to the bundle re-imports it and hot-reloads it
19
+ // through the probe's `reload` (snapshot, swap, restore), printing one JSON
20
+ // line per reload with the latency from the file write to the first frame
21
+ // of the new bundle.
22
+ // - Without a renderer (no usable adapter, as in the platform's engine and
23
+ // probe sandboxes, or OG_RENDERER=none) the run is the same run: the play
24
+ // surface keeps --size, a `screenshot` writes no PNG and is listed in
25
+ // --json's `unchecked` ("no renderer, frame unchecked"), the tier is
26
+ // `none`, and the exit status does not change.
27
+
28
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
29
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
30
+ import { createHash } from "node:crypto";
31
+ import { pathToFileURL } from "node:url";
32
+ import { loadNativeHost, native } from "./index.ts";
33
+ import { openAudio, type AudioSink } from "./audio.ts";
34
+ import { nativePlatform } from "./platform.ts";
35
+ import { beginBundle, createEngine, fixedClock, type Engine, type GameToken } from "../runtime.ts";
36
+
37
+ const argv = process.argv.slice(2);
38
+ let entry = "";
39
+ let root: string | undefined;
40
+ let ticks: number | undefined;
41
+ let seed: number | undefined;
42
+ let commands: string | undefined;
43
+ let out = "og-out";
44
+ let port: number | undefined;
45
+ let size = { width: 1280, height: 720 };
46
+ let realtime = false;
47
+ let watchFile: string | undefined;
48
+ let render = true;
49
+ let asJson = false;
50
+ let audioOut = "null";
51
+ let platformStore: string | undefined;
52
+ let locale: string | undefined;
53
+ let clock: number | undefined;
54
+ const setup: string[] = [];
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i]!;
57
+ const next = () => {
58
+ const v = argv[++i];
59
+ if (v === undefined) fail(`${a} needs a value`);
60
+ return v!;
61
+ };
62
+ if (a === "--entry") entry = next();
63
+ else if (a === "--root") root = resolve(next());
64
+ else if (a === "--ticks") ticks = Number(next());
65
+ else if (a === "--seed") seed = Number(next());
66
+ else if (a === "--setup") setup.push(next());
67
+ else if (a === "--commands") commands = next();
68
+ else if (a === "--out") out = next();
69
+ else if (a === "--port") port = Number(next());
70
+ else if (a === "--size") {
71
+ const m = /^(\d+)x(\d+)$/.exec(next());
72
+ if (!m) fail("--size takes WIDTHxHEIGHT, such as 1280x720");
73
+ size = { width: Number(m![1]), height: Number(m![2]) };
74
+ } else if (a === "--realtime") realtime = true;
75
+ else if (a === "--watch") watchFile = resolve(next());
76
+ else if (a === "--no-render") render = false;
77
+ else if (a === "--json") asJson = true;
78
+ else if (a === "--audio") audioOut = next();
79
+ else if (a === "--platform-store") platformStore = resolve(next());
80
+ else if (a === "--locale") locale = next();
81
+ else if (a === "--clock") clock = Number(next());
82
+ else if (a === "--headless") {
83
+ // Native runs are always headless; accepted for `og run --headless`.
84
+ } else fail(`unknown argument '${a}'`);
85
+ }
86
+
87
+ function fail(reason: string): never {
88
+ process.stderr.write(`og run: ${reason}\n`);
89
+ process.exit(2);
90
+ }
91
+
92
+ if (!entry) fail("no game entry: pass --entry <file>");
93
+ if (ticks !== undefined && (!Number.isInteger(ticks) || ticks < 0)) fail("--ticks takes a whole number of fixed steps");
94
+ if (seed !== undefined && (!Number.isInteger(seed) || seed < 0)) fail("--seed takes a whole number");
95
+ if (clock !== undefined && !(Number.isFinite(clock) && clock > 0)) fail("--clock takes UTC milliseconds since 1970, such as 1767225600000");
96
+ if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535)) fail("--port takes 0 (a free port) to 65535");
97
+
98
+ loadNativeHost();
99
+ const extras = native();
100
+ let version = 0;
101
+ /**
102
+ * Import a bundle as a fresh module. A re-import goes through a copy under
103
+ * its own name: bun keys file:// modules by path and ignores a query, so
104
+ * `game.js?v=2` would answer the module already evaluated, declaring nothing.
105
+ */
106
+ async function importGame(file: string): Promise<GameToken> {
107
+ let path = resolve(file);
108
+ if (version > 0) {
109
+ const copy = path.replace(/(\.[cm]?[jt]s)?$/, (ext) => `.reload-${version}${ext || ".js"}`);
110
+ copyFileSync(path, copy);
111
+ path = copy;
112
+ }
113
+ const url = pathToFileURL(path).href;
114
+ version++;
115
+ const mod = (await import(url)) as { default?: GameToken };
116
+ if (path !== resolve(file)) rmSync(path, { force: true });
117
+ if (!mod.default) fail(`${file} has no default export: export default og.game({ ... })`);
118
+ return mod.default!;
119
+ }
120
+
121
+ /** project.ogt's text, which the engine reads tick, seed, scenes and scene from (CreateOptions.project). */
122
+ function projectText(dir: string | undefined): string | undefined {
123
+ if (!dir || !existsSync(join(dir, "project.ogt"))) return undefined;
124
+ return readFileSync(join(dir, "project.ogt"), "utf8");
125
+ }
126
+
127
+ /** project.ogt's `profile` ("2d" or "3d"), which chooses the physics profile. */
128
+ function projectProfile(text: string | undefined): "2d" | "3d" | undefined {
129
+ const m = text === undefined ? null : /^\s*profile\s+"(2d|3d)"/m.exec(text);
130
+ return m ? (m[1] as "2d" | "3d") : undefined;
131
+ }
132
+
133
+ const game = await importGame(entry);
134
+ const project = projectText(root);
135
+ const profile = projectProfile(project);
136
+ const probing = commands !== undefined || port !== undefined || watchFile !== undefined;
137
+ // With a renderer, GameDesc.start runs once it is attached (as the web Worker
138
+ // does), so og.render.capabilities reports the play surface's size there.
139
+ const deferStart = probing && render;
140
+ const created = createEngine(game, { ...(seed === undefined ? {} : { seed }), ...(root ? { root } : {}), ...(project === undefined ? {} : { project }), ...(profile ? { profile } : {}), ...(locale ? { locale } : {}), ...(deferStart ? { deferStart } : {}) });
141
+ if (!created.ok) fail(`${entry}: ${created.reason}`);
142
+ const engine: Engine = created.value;
143
+ // og.platform: the local store, in memory unless --platform-store names a file;
144
+ // its clock the system's, or with --clock one that starts there and moves with the ticks.
145
+ if (clock !== undefined) engine.setClock(fixedClock(engine, clock));
146
+ engine.attachPlatform(nativePlatform(platformStore));
147
+ // One sink per engine, pumped after every step (crates/og-audio/README.md
148
+ // §Host contract): null keeps the stream drained, a .wav path captures it.
149
+ const audioMode = audioOut === "device" ? "device" : audioOut === "null" ? "null" : "capture";
150
+ const opened = openAudio(engine.id, audioMode);
151
+ if (!opened.ok) fail(`--audio ${audioOut}: ${opened.reason}`);
152
+ const sink: AudioSink = opened.value;
153
+ const outputs: string[] = [];
154
+ const warnings: string[] = [];
155
+ // The screenshots taken without a renderer: the probe's FRAME_UNCHECKED notes.
156
+ const unchecked: string[] = [];
157
+ const UNCHECKED = /^[\d.]+s screenshot: no renderer, frame unchecked \((.+)\)$/;
158
+
159
+ function decode(base64: string): Uint8Array {
160
+ const bin = atob(base64);
161
+ const bytes = new Uint8Array(bin.length);
162
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
163
+ return bytes;
164
+ }
165
+
166
+ function drain(): void {
167
+ for (const o of engine.takeOutputs()) {
168
+ // A script names its output relative to --out; one that would land
169
+ // outside it (an absolute path or `..`) is refused, never written.
170
+ const path = resolve(out, o.file);
171
+ const inside = relative(resolve(out), path);
172
+ if (inside === "" || inside === ".." || inside.startsWith(".." + sep) || isAbsolute(inside)) {
173
+ const line = `WARN tick ${o.tick} ${o.action}: output path '${o.file}' leaves --out ${out}; not written`;
174
+ warnings.push(line);
175
+ process.stderr.write(line + "\n");
176
+ continue;
177
+ }
178
+ mkdirSync(dirname(path), { recursive: true });
179
+ writeFileSync(path, o.kind === "png" || o.kind === "wav" ? decode(o.data) : o.data);
180
+ outputs.push(path);
181
+ if (!asJson) process.stdout.write(`tick ${o.tick} ${o.action}: wrote ${path}\n`);
182
+ }
183
+ for (const l of engine.takeLog()) {
184
+ const line = `${l.warning ? "WARN" : "INFO"} tick ${l.tick}: ${l.text}`;
185
+ if (l.warning) warnings.push(line);
186
+ else {
187
+ const frame = UNCHECKED.exec(l.text);
188
+ if (frame) unchecked.push(frame[1]!);
189
+ }
190
+ process.stderr.write(line + "\n");
191
+ }
192
+ }
193
+
194
+ let tier = "none";
195
+ if (deferStart) {
196
+ const attached = JSON.parse(extras.og_node_attach_offscreen(engine.id, size.width, size.height)) as { ok: boolean; value?: string; reason?: string };
197
+ if (attached.ok) tier = attached.value!;
198
+ else process.stderr.write(`INFO no renderer (${attached.reason}): the run is the same, a screenshot writes no frame and reports it unchecked, capture answers null\n`);
199
+ // The first tick boundary: GameDesc.start, with the surface in place.
200
+ const started = engine.advance(0);
201
+ if (!started.ok) fail(started.reason);
202
+ }
203
+ drain();
204
+
205
+ for (const step of setup) {
206
+ const [action, ...args] = step.split(":");
207
+ const r = engine.command(action!, args);
208
+ if (!r.ok) fail(`--setup ${step}: ${r.reason}`);
209
+ }
210
+
211
+ let listening: number | undefined;
212
+ if (port !== undefined) {
213
+ const r = JSON.parse(extras.og_node_probe_listen(engine.id, port)) as { ok: boolean; value?: number; reason?: string };
214
+ if (!r.ok) fail(r.reason!);
215
+ listening = r.value!;
216
+ process.stdout.write(`probe: ws://127.0.0.1:${listening}/probe\n`);
217
+ }
218
+
219
+ let untilTick = -1;
220
+ if (commands !== undefined) {
221
+ const report = engine.probe.commands(commands) as { scheduled: number; untilTick: number; warnings: string[] } | null;
222
+ drain();
223
+ if (!report) fail(`--commands: the script is malformed (the reason is above)`);
224
+ for (const w of report!.warnings) process.stderr.write(`WARN ${w}\n`);
225
+ untilTick = report!.untilTick;
226
+ }
227
+
228
+ const rate = engine.rate;
229
+ const tickMs = 1000 / rate;
230
+ // --json's frame times: each frame's tick and render milliseconds (the
231
+ // first five left out, as og polish does), and each system's time over the
232
+ // run from the world's dump (World::set_clock).
233
+ type SystemTimes = { name: string; totalMs?: number; runs?: number }[];
234
+ const systemTimes = (): SystemTimes => (engine.probe.dump({ what: "world" }) as { systems?: SystemTimes }).systems ?? [];
235
+ const systemsBefore = asJson ? systemTimes() : [];
236
+ const stepTimes: number[] = [];
237
+ const renderTimes: number[] = [];
238
+ let framesSeen = 0;
239
+ const t0 = performance.now();
240
+ let frameFailed: string | null = null;
241
+ let quit = false;
242
+
243
+ if (!probing) {
244
+ const advanced = engine.advance(ticks ?? 0);
245
+ if (!advanced.ok) fail(advanced.reason);
246
+ sink.pump();
247
+ } else {
248
+ let pendingReload: { mtimeMs: number } | null = null;
249
+ // Imports in flight: a reload swaps only once the last one has declared
250
+ // everything (a second write of the bundle starts another import, whose
251
+ // beginBundle clears the declarations the first one made).
252
+ let importing = 0;
253
+ if (watchFile) {
254
+ let busy = false;
255
+ watch(watchFile, () => {
256
+ if (busy) return;
257
+ busy = true;
258
+ setTimeout(async () => {
259
+ busy = false;
260
+ const mtimeMs = statSync(watchFile!).mtimeMs;
261
+ importing++;
262
+ try {
263
+ beginBundle();
264
+ await importGame(watchFile!);
265
+ pendingReload = { mtimeMs };
266
+ } catch (e) {
267
+ process.stderr.write(`WARN reload: the bundle failed to import (${e instanceof Error ? e.message : String(e)}); the old one keeps running\n`);
268
+ } finally {
269
+ importing--;
270
+ }
271
+ }, 5);
272
+ });
273
+ }
274
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
275
+ let last = performance.now();
276
+ for (;;) {
277
+ if (pendingReload && importing === 0) {
278
+ const { mtimeMs } = pendingReload as { mtimeMs: number };
279
+ pendingReload = null;
280
+ const report = engine.probe.reload() as { ms: number; tick: number } | null;
281
+ const first = engine.frame(0);
282
+ const captured = render && engine.probe.capture(0, 0) !== null;
283
+ const latencyMs = Date.now() - mtimeMs;
284
+ process.stdout.write(JSON.stringify({ event: "reload", ok: report !== null, report, tick: first.tick, captured, latencyMs: +latencyMs.toFixed(1) }) + "\n");
285
+ drain();
286
+ }
287
+ const elapsed = realtime ? performance.now() - last : tickMs;
288
+ last = performance.now();
289
+ const answer = engine.frame(elapsed);
290
+ if (!answer.ok) {
291
+ frameFailed = answer.reason ?? "the frame failed";
292
+ break;
293
+ }
294
+ if (asJson && ++framesSeen > 5) {
295
+ if ((answer.ticks ?? 0) > 0) stepTimes.push(answer.stepMs ?? 0);
296
+ if (answer.rendered) renderTimes.push(answer.renderMs ?? 0);
297
+ }
298
+ sink.pump();
299
+ if (listening !== undefined) extras.og_node_probe_serve(engine.id);
300
+ drain();
301
+ const tick = answer.tick ?? 0;
302
+ if (answer.quit) {
303
+ quit = true;
304
+ break;
305
+ }
306
+ if (ticks !== undefined && tick >= ticks) break;
307
+ if (ticks === undefined && commands !== undefined && listening === undefined && !watchFile && tick > untilTick) break;
308
+ if (realtime || listening !== undefined || watchFile) await sleep(Math.max(0, tickMs - (performance.now() - last)));
309
+ }
310
+ }
311
+ const ms = performance.now() - t0;
312
+ drain();
313
+ let wavSha256: string | undefined;
314
+ if (audioMode === "capture") {
315
+ const wavPath = resolve(audioOut);
316
+ mkdirSync(dirname(wavPath), { recursive: true });
317
+ const wav = sink.wav();
318
+ writeFileSync(wavPath, wav);
319
+ wavSha256 = createHash("sha256").update(wav).digest("hex");
320
+ outputs.push(wavPath);
321
+ }
322
+ const audioStats = sink.stats();
323
+ sink.close();
324
+ if (frameFailed) process.stderr.write(`og run: ${frameFailed}\n`);
325
+ const dump = engine.probe.dump({ what: "world" }) as unknown as { hash: string; entities: number; tick: number; systems?: SystemTimes };
326
+ // og polish's order statistics (src/node/polish.ts), so the two agree.
327
+ const percentile = (xs: number[], q: number): number => {
328
+ if (xs.length === 0) return 0;
329
+ const s = [...xs].sort((a, b) => a - b);
330
+ return s[Math.min(s.length - 1, Math.floor(q * s.length))]!;
331
+ };
332
+ const r3 = (x: number) => +x.toFixed(3);
333
+ const frames = {
334
+ stepMedianMs: r3(percentile(stepTimes, 0.5)),
335
+ stepP95Ms: r3(percentile(stepTimes, 0.95)),
336
+ stepMaxMs: r3(Math.max(0, ...stepTimes)),
337
+ renderMedianMs: r3(percentile(renderTimes, 0.5)),
338
+ renderP95Ms: r3(percentile(renderTimes, 0.95)),
339
+ renderMaxMs: r3(Math.max(0, ...renderTimes)),
340
+ stepped: stepTimes.length,
341
+ rendered: renderTimes.length,
342
+ };
343
+ // Each system's mean milliseconds a run over this run, slowest first.
344
+ const systems = (dump.systems ?? [])
345
+ .map((s) => {
346
+ const before = systemsBefore.find((b) => b.name === s.name);
347
+ const runs = (s.runs ?? 0) - (before?.runs ?? 0);
348
+ const ms = (s.totalMs ?? 0) - (before?.totalMs ?? 0);
349
+ return { name: s.name, meanMs: runs > 0 ? r3(ms / runs) : 0, totalMs: r3(ms), runs };
350
+ })
351
+ .filter((s) => s.runs > 0)
352
+ .sort((a, b) => b.totalMs - a.totalMs);
353
+ const summary = {
354
+ entry,
355
+ ticks: ticks ?? dump.tick,
356
+ tick: dump.tick,
357
+ hash: dump.hash,
358
+ entities: dump.entities,
359
+ advanceMs: +ms.toFixed(2),
360
+ frames,
361
+ systems,
362
+ runtime: typeof Bun !== "undefined" ? `bun ${Bun.version}` : `node ${process.version}`,
363
+ tier,
364
+ quit,
365
+ unchecked,
366
+ audio: { mode: audioMode, commands: audioStats.commands, receivedHash: audioStats.receivedHash, wavSha256 },
367
+ outputs,
368
+ warnings,
369
+ };
370
+ if (asJson) process.stdout.write(JSON.stringify(summary) + "\n");
371
+ else process.stdout.write(`tick ${dump.tick} hash ${dump.hash} entities ${dump.entities}\n`);
372
+ if (listening !== undefined) extras.og_node_probe_close(engine.id);
373
+ process.exit(frameFailed ? 1 : 0);