@origingame/engine 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/bin/og.mjs +1 -1
  2. package/dist/web/origin-engine.d.ts +219 -5
  3. package/dist/web/origin-engine.js +1344 -125
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +68 -3
  6. package/dist/web-publish/origin-engine-lanes.js +1007 -22
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +162 -5
  9. package/dist/web-publish/origin-engine.js +1004 -19
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +52 -3
  12. package/dist/web-publish-2d/origin-engine-lanes.js +1007 -22
  13. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  14. package/dist/web-publish-2d/origin-engine.d.ts +162 -5
  15. package/dist/web-publish-2d/origin-engine.js +1004 -19
  16. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +52 -3
  18. package/knowledge/looks/catalogue.json +40 -40
  19. package/knowledge/package.json +1 -1
  20. package/knowledge/scaffold/distribution.json +14 -0
  21. package/knowledge/scaffold/origin-engine.json +2 -2
  22. package/knowledge/scaffold/template/README.md +2 -1
  23. package/knowledge/scaffold/template/assets/audio/sting.ogt +10 -0
  24. package/knowledge/scaffold/template/assets/timelines/intro.ogt +101 -0
  25. package/knowledge/scaffold/template/assets/timelines/narration.srt +11 -0
  26. package/knowledge/scaffold/template/host.d.ts +46 -0
  27. package/knowledge/scaffold/template/package.json +1 -1
  28. package/knowledge/scaffold/template/project.ogt +1 -1
  29. package/knowledge/scaffold/template/src/main.ts +1 -1
  30. package/knowledge/scaffold/template/tests/frames.ts +134 -0
  31. package/knowledge/scaffold/template/tests/intro.og.ts +65 -0
  32. package/knowledge/scaffold/template/tests/smoke.og.ts +30 -36
  33. package/knowledge/scaffold/template/tsconfig.json +1 -1
  34. package/knowledge/scripts/mount.ts +2 -1
  35. package/knowledge/scripts/validate.ts +19 -10
  36. package/knowledge/skills/og-build/SKILL.md +8 -5
  37. package/knowledge/skills/og-core/SKILL.md +11 -0
  38. package/knowledge/skills/og-game/SKILL.md +877 -8
  39. package/knowledge/skills/og-platform-sdk/SKILL.md +1 -0
  40. package/knowledge/skills/og-taste/SKILL.md +82 -73
  41. package/knowledge/skills/og-ui/SKILL.md +65 -50
  42. package/knowledge/wiki/ai.md +20 -20
  43. package/knowledge/wiki/anim.md +384 -41
  44. package/knowledge/wiki/atmosphere.md +36 -13
  45. package/knowledge/wiki/audio.md +18 -18
  46. package/knowledge/wiki/camera.md +19 -13
  47. package/knowledge/wiki/commands.md +19 -13
  48. package/knowledge/wiki/debug.md +414 -0
  49. package/knowledge/wiki/game.md +8 -8
  50. package/knowledge/wiki/i18n.md +7 -7
  51. package/knowledge/wiki/index.md +262 -7
  52. package/knowledge/wiki/input.md +47 -47
  53. package/knowledge/wiki/load.md +236 -68
  54. package/knowledge/wiki/math.md +2607 -0
  55. package/knowledge/wiki/nav.md +34 -34
  56. package/knowledge/wiki/net.md +340 -0
  57. package/knowledge/wiki/path.md +402 -0
  58. package/knowledge/wiki/physics.md +95 -95
  59. package/knowledge/wiki/platform.md +463 -29
  60. package/knowledge/wiki/probe.md +681 -67
  61. package/knowledge/wiki/reflect.md +15 -15
  62. package/knowledge/wiki/render.md +3893 -1896
  63. package/knowledge/wiki/rng.md +14 -14
  64. package/knowledge/wiki/stream.md +15 -15
  65. package/knowledge/wiki/system.md +8 -8
  66. package/knowledge/wiki/systems.md +2 -2
  67. package/knowledge/wiki/terrain.md +19 -19
  68. package/knowledge/wiki/tilemap.md +13 -13
  69. package/knowledge/wiki/time.md +4 -4
  70. package/knowledge/wiki/timeline.md +396 -0
  71. package/knowledge/wiki/transform.md +8 -8
  72. package/knowledge/wiki/tween.md +17 -17
  73. package/knowledge/wiki/ui.md +559 -629
  74. package/knowledge/wiki/video.md +157 -0
  75. package/knowledge/wiki/world.md +36 -36
  76. package/package.json +4 -3
  77. package/schema/api.schema.json +21707 -14725
  78. package/shell/page.ts +26 -2
  79. package/shell/platform.ts +46 -0
  80. package/shell/player.ts +7 -0
  81. package/src/depth.ts +71 -0
  82. package/src/e6.ts +32 -1
  83. package/src/env.d.ts +5 -1
  84. package/src/generated/abi.ts +189 -3
  85. package/src/index.ts +25 -4
  86. package/src/light.ts +18 -0
  87. package/src/math.ts +622 -0
  88. package/src/node/index.ts +17 -0
  89. package/src/node/playtest-run.ts +114 -18
  90. package/src/node/polish.ts +13 -3
  91. package/src/node/render.ts +291 -0
  92. package/src/node/run.ts +86 -5
  93. package/src/p2.ts +19 -1
  94. package/src/playtest.ts +57 -4
  95. package/src/runtime.ts +13 -3
  96. package/src/targets.ts +28 -0
  97. package/src/trace.ts +169 -0
  98. package/src/ui-kit/huds.ts +34 -34
  99. package/src/ui-kit/menus.ts +12 -12
  100. package/src/ui-kit/settings.ts +2 -2
  101. package/src/ui-kit/store.ts +18 -18
  102. package/src/ui-kit/story.ts +6 -4
  103. package/src/ui-kit/util.ts +8 -8
  104. package/src/web/shell.ts +58 -0
  105. package/src/web/worker.ts +103 -2
  106. package/types/bindings.json +9101 -6647
  107. package/types/index.d.ts +15503 -11564
package/src/node/run.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  // bun src/node/run.ts --entry <game.js|.ts> [--root <project>] [--ticks N] [--seed S]
6
6
  // [--setup action[:arg...]]... [--commands "t:action[:args];..."] [--out <dir>]
7
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]
8
+ // [--audio null|device|<out.wav>] [--platform-store <file.json>] [--clock <UTC ms>] [--locale <tag>]
9
+ // [--trace <out.json> [--trace-append]] [--room ws://127.0.0.1:P/<room>] [--json]
9
10
  //
10
11
  // - Without --commands, --port or --watch: advance --ticks N at once and print
11
12
  // the world hash (M2's hello world).
@@ -19,11 +20,21 @@
19
20
  // through the probe's `reload` (snapshot, swap, restore), printing one JSON
20
21
  // line per reload with the latency from the file write to the first frame
21
22
  // of the new bundle.
23
+ // - With --room: the engine joins that room through `og relay` (og.platform's
24
+ // rooms, og.net's lockstep), the link pumped around every frame, and the run
25
+ // takes frames (never one big advance), so a tick lockstep holds waits for
26
+ // the other players' inputs; it ends at `quit`, --ticks or the script's end.
22
27
  // - Without a renderer (no usable adapter, as in the platform's engine and
23
28
  // probe sandboxes, or OG_RENDERER=none) the run is the same run: the play
24
29
  // surface keeps --size, a `screenshot` writes no PNG and is listed in
25
30
  // --json's `unchecked` ("no renderer, frame unchecked"), the tier is
26
31
  // `none`, and the exit status does not change.
32
+ // - With --trace: the run in the Chrome trace format (src/trace.ts and
33
+ // crates/og-runtime/src/trace.rs have the threads): each tick with its
34
+ // systems, each frame with its render passes' CPU encoding and their GPU
35
+ // time, each load, og-lanes' jobs on their pool threads, every span where
36
+ // it ran; --trace-append adds them to the file og wrote first (its
37
+ // metadata, its origin and the cook's spans).
27
38
 
28
39
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
29
40
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -32,7 +43,8 @@ import { pathToFileURL } from "node:url";
32
43
  import { loadNativeHost, native } from "./index.ts";
33
44
  import { openAudio, type AudioSink } from "./audio.ts";
34
45
  import { nativePlatform } from "./platform.ts";
35
- import { beginBundle, createEngine, fixedClock, type Engine, type GameToken } from "../runtime.ts";
46
+ import { Trace, unixMs, type TraceDocument } from "../trace.ts";
47
+ import { beginBundle, boundHost, createEngine, fixedClock, type Engine, type GameToken } from "../runtime.ts";
36
48
 
37
49
  const argv = process.argv.slice(2);
38
50
  let entry = "";
@@ -51,6 +63,9 @@ let audioOut = "null";
51
63
  let platformStore: string | undefined;
52
64
  let locale: string | undefined;
53
65
  let clock: number | undefined;
66
+ let tracePath: string | undefined;
67
+ let traceAppend = false;
68
+ let room: string | undefined;
54
69
  const setup: string[] = [];
55
70
  for (let i = 0; i < argv.length; i++) {
56
71
  const a = argv[i]!;
@@ -79,6 +94,9 @@ for (let i = 0; i < argv.length; i++) {
79
94
  else if (a === "--platform-store") platformStore = resolve(next());
80
95
  else if (a === "--locale") locale = next();
81
96
  else if (a === "--clock") clock = Number(next());
97
+ else if (a === "--trace") tracePath = resolve(next());
98
+ else if (a === "--trace-append") traceAppend = true;
99
+ else if (a === "--room") room = next();
82
100
  else if (a === "--headless") {
83
101
  // Native runs are always headless; accepted for `og run --headless`.
84
102
  } else fail(`unknown argument '${a}'`);
@@ -97,6 +115,17 @@ if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535))
97
115
 
98
116
  loadNativeHost();
99
117
  const extras = native();
118
+
119
+ // --trace: the Chrome trace (src/trace.ts), continuing og's prelude (the cook) when appending.
120
+ const trace = tracePath
121
+ ? new Trace({ producer: "og run --trace", process: "og run", ...(traceAppend && existsSync(tracePath) ? { prior: JSON.parse(readFileSync(tracePath, "utf8")) as Partial<TraceDocument> } : {}) })
122
+ : undefined;
123
+ /** Takes the engine's spans (each where it ran) onto the trace; `last` waits for the GPU's frames in flight. */
124
+ function takeTrace(engineId: number, last = false): void {
125
+ if (!trace) return;
126
+ const taken = extras.og_node_trace_take(engineId, last);
127
+ trace.append(taken, unixMs());
128
+ }
100
129
  let version = 0;
101
130
  /**
102
131
  * Import a bundle as a fresh module. A re-import goes through a copy under
@@ -131,15 +160,22 @@ function projectProfile(text: string | undefined): "2d" | "3d" | undefined {
131
160
  }
132
161
 
133
162
  const game = await importGame(entry);
163
+ // Loads made while the engine is created (its boot scene's) are traced too.
164
+ const BEFORE_ENGINE = 0xffffffff;
165
+ if (tracePath) extras.og_node_trace(BEFORE_ENGINE, true);
134
166
  const project = projectText(root);
135
167
  const profile = projectProfile(project);
136
- const probing = commands !== undefined || port !== undefined || watchFile !== undefined;
168
+ const probing = commands !== undefined || port !== undefined || watchFile !== undefined || room !== undefined;
137
169
  // With a renderer, GameDesc.start runs once it is attached (as the web Worker
138
170
  // does), so og.render.capabilities reports the play surface's size there.
139
171
  const deferStart = probing && render;
140
172
  const created = createEngine(game, { ...(seed === undefined ? {} : { seed }), ...(root ? { root } : {}), ...(project === undefined ? {} : { project }), ...(profile ? { profile } : {}), ...(locale ? { locale } : {}), ...(deferStart ? { deferStart } : {}) });
141
173
  if (!created.ok) fail(`${entry}: ${created.reason}`);
142
174
  const engine: Engine = created.value;
175
+ if (tracePath) {
176
+ extras.og_node_trace(engine.id, true);
177
+ extras.og_node_trace(BEFORE_ENGINE, false);
178
+ }
143
179
  // og.platform: the local store, in memory unless --platform-store names a file;
144
180
  // its clock the system's, or with --clock one that starts there and moves with the ticks.
145
181
  if (clock !== undefined) engine.setClock(fixedClock(engine, clock));
@@ -216,6 +252,23 @@ if (port !== undefined) {
216
252
  process.stdout.write(`probe: ws://127.0.0.1:${listening}/probe\n`);
217
253
  }
218
254
 
255
+ if (room !== undefined) {
256
+ const linked = JSON.parse(extras.og_node_room_connect(engine.id, room)) as { ok: boolean; reason?: string };
257
+ if (!linked.ok) fail(`--room ${room}: ${linked.reason}`);
258
+ // The relay joins the room the URL names: wait for its answer before the first tick.
259
+ for (let i = 0; i < 2000 && (JSON.parse(abiRoomInfo()) as { state: string }).state !== "in"; i++) {
260
+ extras.og_node_room_pump(engine.id);
261
+ await new Promise((r) => setTimeout(r, 1));
262
+ }
263
+ }
264
+
265
+ function abiRoomInfo(): string {
266
+ return boundHost()!.abi.og_platform_room_info(engine.id);
267
+ }
268
+
269
+ type NetStatus = { running: boolean; startTick: number | null; waits: number; desync: number | null };
270
+ const netStatus = (): NetStatus => JSON.parse(boundHost()!.abi.og_net_status(engine.id)) as NetStatus;
271
+
219
272
  let untilTick = -1;
220
273
  if (commands !== undefined) {
221
274
  const report = engine.probe.commands(commands) as { scheduled: number; untilTick: number; warnings: string[] } | null;
@@ -286,7 +339,9 @@ if (!probing) {
286
339
  }
287
340
  const elapsed = realtime ? performance.now() - last : tickMs;
288
341
  last = performance.now();
342
+ if (room !== undefined) extras.og_node_room_pump(engine.id);
289
343
  const answer = engine.frame(elapsed);
344
+ if (room !== undefined) extras.og_node_room_pump(engine.id);
290
345
  if (!answer.ok) {
291
346
  frameFailed = answer.reason ?? "the frame failed";
292
347
  break;
@@ -297,6 +352,7 @@ if (!probing) {
297
352
  }
298
353
  sink.pump();
299
354
  if (listening !== undefined) extras.og_node_probe_serve(engine.id);
355
+ takeTrace(engine.id);
300
356
  drain();
301
357
  const tick = answer.tick ?? 0;
302
358
  if (answer.quit) {
@@ -310,10 +366,22 @@ if (!probing) {
310
366
  const left = answer.pending ?? (tick > untilTick ? 0 : 1);
311
367
  if (ticks === undefined && commands !== undefined && listening === undefined && !watchFile && left === 0) break;
312
368
  if (realtime || listening !== undefined || watchFile) await sleep(Math.max(0, tickMs - (performance.now() - last)));
369
+ // In a room a frame that took no tick waits for the other players' inputs,
370
+ // and outside lockstep (its lobby, or a game that leaves it off) the run
371
+ // keeps the tick's pace with the other peers: an unpaced lobby would
372
+ // spend the run's --ticks before they arrive.
373
+ else if (room !== undefined && (answer.ticks ?? 0) === 0) await sleep(1);
374
+ else if (room !== undefined && !netStatus().running) await sleep(Math.max(0, tickMs - (performance.now() - last)));
313
375
  }
314
376
  }
315
377
  const ms = performance.now() - t0;
316
378
  drain();
379
+ if (tracePath) {
380
+ takeTrace(engine.id, true);
381
+ mkdirSync(dirname(tracePath), { recursive: true });
382
+ writeFileSync(tracePath, JSON.stringify(trace!.document()));
383
+ outputs.push(tracePath);
384
+ }
317
385
  let wavSha256: string | undefined;
318
386
  if (audioMode === "capture") {
319
387
  const wavPath = resolve(audioOut);
@@ -327,6 +395,8 @@ const audioStats = sink.stats();
327
395
  sink.close();
328
396
  if (frameFailed) process.stderr.write(`og run: ${frameFailed}\n`);
329
397
  const dump = engine.probe.dump({ what: "world" }) as unknown as { hash: string; entities: number; tick: number; systems?: SystemTimes };
398
+ // The script's expectations (`expect:` steps): a failure fails the run.
399
+ const expectations = engine.probe.expectations();
330
400
  // og polish's order statistics (src/node/polish.ts), so the two agree.
331
401
  const percentile = (xs: number[], q: number): number => {
332
402
  if (xs.length === 0) return 0;
@@ -359,19 +429,30 @@ const summary = {
359
429
  ticks: ticks ?? dump.tick,
360
430
  tick: dump.tick,
361
431
  hash: dump.hash,
432
+ // og.net's shared hash: the world without this peer's session, what lockstep peers compare.
433
+ netHash: JSON.parse(boundHost()!.abi.og_net_hash(engine.id)) as string,
434
+ // With --room: lockstep's state at the end (the tick it started, the waits, the first divergent tick).
435
+ ...(room !== undefined ? { lockstep: netStatus() } : {}),
362
436
  entities: dump.entities,
363
437
  advanceMs: +ms.toFixed(2),
364
438
  frames,
365
439
  systems,
366
440
  runtime: typeof Bun !== "undefined" ? `bun ${Bun.version}` : `node ${process.version}`,
367
441
  tier,
442
+ // Whether the device timed each render pass on the GPU (RenderCapabilities.gpuTimestamps): the trace's gpu thread and ProbeTimings.gpuPasses.
443
+ gpuTimestamps: (JSON.parse(boundHost()!.abi.og_render_capabilities(engine.id)) as { gpuTimestamps?: boolean }).gpuTimestamps ?? false,
368
444
  quit,
369
445
  unchecked,
370
446
  audio: { mode: audioMode, commands: audioStats.commands, receivedHash: audioStats.receivedHash, wavSha256 },
371
447
  outputs,
372
448
  warnings,
449
+ expectations,
373
450
  };
374
451
  if (asJson) process.stdout.write(JSON.stringify(summary) + "\n");
375
- else process.stdout.write(`tick ${dump.tick} hash ${dump.hash} entities ${dump.entities}\n`);
452
+ else {
453
+ process.stdout.write(`tick ${dump.tick} hash ${dump.hash} entities ${dump.entities}\n`);
454
+ if (expectations.results.length > 0) process.stdout.write(`expectations: ${expectations.passed} passed, ${expectations.failed} failed, ${expectations.unchecked} unchecked\n`);
455
+ }
376
456
  if (listening !== undefined) extras.og_node_probe_close(engine.id);
377
- process.exit(frameFailed ? 1 : 0);
457
+ if (room !== undefined) extras.og_node_room_close(engine.id);
458
+ process.exit(frameFailed || expectations.failed > 0 ? 1 : 0);
package/src/p2.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The phase 2 modules' namespaces (IDL `terrain`): thin calls into the host's binding symbols,
1
+ // The phase 2 and depth modules' namespaces (IDL `terrain`, `stream`, `path`, `timeline`): thin calls into the host's binding symbols,
2
2
  // typed by the generated declarations through index.ts.
3
3
 
4
4
  import type { HostAbi } from "./generated/abi.ts";
@@ -33,3 +33,21 @@ export const stream = Object.freeze({
33
33
  budget: (world: W) => parse(abi().og_stream_budget(world.id)),
34
34
  setBudget: (world: W, bytes: number) => parse(abi().og_stream_set_budget(world.id, bytes)),
35
35
  });
36
+
37
+ // Depth S2 (IDL `path`, `timeline`).
38
+ export const path = Object.freeze({
39
+ create: (world: W, desc?: unknown) => parse(abi().og_path_create(world.id, json(desc))),
40
+ find: (world: W, name: string) => parse(abi().og_path_find(world.id, name)),
41
+ sample: (world: W, p: number, t: number) => parse(abi().og_path_sample(world.id, p, t)),
42
+ length: (world: W, p: number) => parse(abi().og_path_length(world.id, p)),
43
+ nearest: (world: W, p: number, point: unknown) =>
44
+ parse(abi().og_path_nearest(world.id, p, JSON.stringify(point))),
45
+ });
46
+
47
+ export const timeline = Object.freeze({
48
+ info: (world: W, asset: number) => parse(abi().og_timeline_info(world.id, asset)),
49
+ play: (world: W, entity: number) => parse(abi().og_timeline_play(world.id, entity)),
50
+ pause: (world: W, entity: number) => parse(abi().og_timeline_pause(world.id, entity)),
51
+ seek: (world: W, entity: number, time: number) => parse(abi().og_timeline_seek(world.id, entity, time)),
52
+ stop: (world: W, entity: number) => parse(abi().og_timeline_stop(world.id, entity)),
53
+ });
package/src/playtest.ts CHANGED
@@ -52,6 +52,42 @@ export interface PlaytestRun {
52
52
  wav(file: string): { rate: number; frames: number; peak: number; rms: number };
53
53
  /** The runs of PlaytestDesc.then, in order, each with its own files (empty without `then`). */
54
54
  readonly then: readonly PlaytestRun[];
55
+ /** og.net's shared hash at the end (og.net.hash): the world without this process's session, what lockstep peers compare. */
56
+ readonly netHash: string;
57
+ /** With PlaytestDesc.room: og.net.status at the end (running, the start tick, the seats, the first divergent tick); null outside a room. */
58
+ readonly lockstep: PlaytestLockstep | null;
59
+ /** With PlaytestDesc.room: the room's other processes (peer scripts 1, 2, ...), each with its own files; empty outside a room. */
60
+ readonly peers: readonly PlaytestRun[];
61
+ }
62
+
63
+ /** og.net.status of a playtest process in a room, at its end. */
64
+ export interface PlaytestLockstep {
65
+ readonly running: boolean;
66
+ readonly peer: number | null;
67
+ readonly startTick: number | null;
68
+ readonly players: number | null;
69
+ readonly waits: number;
70
+ /** The first divergent tick, once og.Desync was sent. */
71
+ readonly desync: number | null;
72
+ }
73
+
74
+ /** PlaytestDesc.room: the script's processes play together in one room. */
75
+ export interface PlaytestRoom {
76
+ /** How many processes join the room (2 to 16). */
77
+ readonly players: number;
78
+ /** Each process's script by its place (0 is the playtest's own `commands`); a process without one runs `commands`. */
79
+ readonly commands?: readonly string[];
80
+ /** The room's name in the relay (default `playtest`). */
81
+ readonly name?: string;
82
+ /**
83
+ * The tick every process stops at (`og run --ticks`), when the scripts do not end first: their
84
+ * `netHash` there is what lockstep makes equal. Leave it out to end each script by its `quit`
85
+ * (or its last step). A script's steps are on the room's shared ticks once lockstep runs (a
86
+ * peer that takes the authority's snapshot at the start moves to its tick and its steps stay
87
+ * on theirs, a step whose tick it jumped past running at once), so a `quit` or an `expect` at
88
+ * tick t runs at tick t on every peer.
89
+ */
90
+ readonly ticks?: number;
55
91
  }
56
92
 
57
93
  /** The checks a playtest makes; a playtest that made none fails. */
@@ -106,9 +142,25 @@ export interface PlaytestDesc {
106
142
  * process has its own store in memory.
107
143
  */
108
144
  readonly store?: "keep";
109
- /** More processes after the script, in order, each a fresh `og run` (a relaunch): their runs are `run.then`. */
145
+ /**
146
+ * Play the script in a room (docs/design.md §2.8, §2.10): the playtest starts a relay
147
+ * (`og relay`, in its own process) and runs `players` processes at once, each `og run
148
+ * --room ws://127.0.0.1:P/<name>` with its own script (`room.commands[i]`, else `commands`)
149
+ * and its own files. The first is the run; the others are `run.peers`. Every process stops
150
+ * at `room.ticks`, or at its script's `quit`, which lands on one tick of the room for all.
151
+ * `runs` is refused with it; each `then` step relaunches every process at once into a room of
152
+ * its own (`<name>.then-<n>`), each running the step's script, its runs in `run.then[n].peers`;
153
+ * `store: "keep"` keeps one store per place across them.
154
+ */
155
+ readonly room?: PlaytestRoom;
156
+ /** More processes after the script, in order, each a fresh `og run` (a relaunch): their runs are `run.then`. With `room`, every place of the room relaunches at once. */
110
157
  readonly then?: readonly PlaytestStep[];
111
- readonly check: (run: PlaytestRun, t: PlaytestChecks) => void | Promise<void>;
158
+ /**
159
+ * Checks over what the run wrote. Optional when the script carries `expect:` steps: each
160
+ * expectation is a check (a failed one fails the playtest with its tick and observed value,
161
+ * a frame one without a renderer is unchecked), so a playtest can be its script alone.
162
+ */
163
+ readonly check?: (run: PlaytestRun, t: PlaytestChecks) => void | Promise<void>;
112
164
  }
113
165
 
114
166
  /** A later process of a playtest (`PlaytestDesc.then`). */
@@ -124,8 +176,9 @@ export const PLAYTEST_CLOCK = 1767225600000;
124
176
 
125
177
  /** Declare a playtest: the default export of a `tests/*.og.ts` file. */
126
178
  export function playtest(desc: PlaytestDesc): PlaytestDesc {
127
- if (!desc.name || !desc.commands || typeof desc.check !== "function") {
128
- throw new Error("playtest({ name, commands, check }) needs all three");
179
+ const expects = /(^|;)\s*[\d.]+:expect:/.test(desc.commands ?? "");
180
+ if (!desc.name || !desc.commands || (typeof desc.check !== "function" && !expects)) {
181
+ throw new Error("playtest({ name, commands, check }) needs all three (check may be left out when the commands carry expect: steps)");
129
182
  }
130
183
  return Object.freeze({ ...desc });
131
184
  }
package/src/runtime.ts CHANGED
@@ -1311,6 +1311,10 @@ function makeProbe(id: number) {
1311
1311
  commands: (script: string) => parse(a().og_engine_probe_commands(id, script)),
1312
1312
  listen: (seconds?: number) => parse(a().og_engine_probe_listen(id, seconds)),
1313
1313
  memory: () => parse(a().og_engine_probe_memory(id)),
1314
+ describe: () => parse(a().og_engine_probe_describe(id)),
1315
+ expect: (what: string) => parse(a().og_engine_probe_expect(id, what)),
1316
+ expectations: () => parse(a().og_engine_probe_expectations(id)),
1317
+ saveScene: (write?: boolean, spawned?: boolean) => parse(a().og_engine_probe_save_scene(id, write, spawned)),
1314
1318
  });
1315
1319
  }
1316
1320
 
@@ -1363,17 +1367,21 @@ export const load = Object.freeze({
1363
1367
  environment: (world: WorldImpl, path: string) => parse(abi().og_load_environment(world.id, path)),
1364
1368
  emitter: (world: WorldImpl, path: string) => parse(abi().og_load_emitter(world.id, path)),
1365
1369
  shader: (world: WorldImpl, path: string) => parse(abi().og_load_shader(world.id, path)),
1366
- skin: (world: WorldImpl, path: string) => parse(abi().og_load_skin(world.id, path)),
1370
+ style: (world: WorldImpl, path: string) => parse(abi().og_load_style(world.id, path)),
1367
1371
  look: (world: WorldImpl, path: string) => parse(abi().og_load_look(world.id, path)),
1368
1372
  lookInfo: (world: WorldImpl, look: number) => parse(abi().og_load_look_info(world.id, look)),
1369
1373
  palette: (world: WorldImpl, path: string) => parse(abi().og_load_palette(world.id, path)),
1370
1374
  soundInfo: (world: WorldImpl, sound: number) => parse(abi().og_load_sound_info(world.id, sound)),
1375
+ video: (world: WorldImpl, path: string) => parse(abi().og_load_video(world.id, path)),
1376
+ videoInfo: (world: WorldImpl, video: number) => parse(abi().og_load_video_info(world.id, video)),
1371
1377
  status: (world: WorldImpl, asset: number) => abi().og_load_status(world.id, asset),
1372
1378
  error: (world: WorldImpl, asset: number) => parse(abi().og_load_error(world.id, asset)),
1373
1379
  gltfInfo: (world: WorldImpl, asset: number) => parse(abi().og_load_gltf_info(world.id, asset)),
1374
1380
  animation: (world: WorldImpl, path: string) => parse(abi().og_load_animation(world.id, path)),
1381
+ timeline: (world: WorldImpl, path: string) => parse(abi().og_load_timeline(world.id, path)),
1375
1382
  flipbook: (world: WorldImpl, path: string) => parse(abi().og_load_flipbook(world.id, path)),
1376
1383
  rig: (world: WorldImpl, path: string) => parse(abi().og_load_rig(world.id, path)),
1384
+ rig2d: (world: WorldImpl, path: string) => parse(abi().og_load_rig2d(world.id, path)),
1377
1385
  tilemap: (world: WorldImpl, path: string) => parse(abi().og_load_tilemap(world.id, path)),
1378
1386
  });
1379
1387
 
@@ -1381,8 +1389,10 @@ export const anim = Object.freeze({
1381
1389
  graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
1382
1390
  humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
1383
1391
  retarget: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_retarget(world.id, json(desc))),
1384
- // One lookup for a flipbook's clips (an index, -1 when missing) and a
1385
- // loaded model's (og.load.animation: a ClipAsset, 0 when missing).
1392
+ rig2dInfo: (world: WorldImpl, rig: number) => parse(abi().og_anim_rig2d_info(world.id, rig)),
1393
+ skeleton2dBoxes: (world: WorldImpl, entity: number) => parse(abi().og_anim_skeleton2d_boxes(world.id, entity)),
1394
+ // One lookup for a flipbook's or a 2D rig's clips (an index, -1 when
1395
+ // missing) and a loaded model's (og.load.animation: a ClipAsset, 0 when missing).
1386
1396
  clip: (info: { clips?: ReadonlyArray<{ clip: number; name: string }>; skeleton?: number }, name: string): number =>
1387
1397
  info?.clips?.find((c) => c.name === name)?.clip ?? (info?.skeleton !== undefined ? 0 : -1),
1388
1398
  });
package/src/targets.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Depth S8's additions (IDL `render` render targets and custom post
2
+ // passes, `load.post`): thin calls into the host's binding symbols, typed by
3
+ // the generated declarations through index.ts.
4
+
5
+ import type { HostAbi } from "./generated/abi.ts";
6
+ import { boundHost } from "./runtime.ts";
7
+
8
+ type W = { readonly id: number };
9
+
10
+ function abi(): HostAbi<Uint32Array, unknown> {
11
+ const h = boundHost();
12
+ if (!h) throw new Error("no engine host is loaded: start the game through @origingame/engine's web or node loader");
13
+ return h.abi;
14
+ }
15
+
16
+ const parse = <T>(text: string): T => JSON.parse(text) as T;
17
+ const json = (value: unknown): string | undefined => (value === undefined ? undefined : JSON.stringify(value));
18
+
19
+ export const targetsRender = Object.freeze({
20
+ renderTarget: (world: W, desc?: unknown) => parse(abi().og_render_render_target(world.id, json(desc))),
21
+ setPostParam: (world: W, post: number, name: string, value: ReadonlyArray<number>) =>
22
+ parse(abi().og_render_set_post_param(world.id, post, name, JSON.stringify(value))),
23
+ postErrors: (world: W) => parse(abi().og_render_post_errors(world.id)),
24
+ });
25
+
26
+ export const targetsLoad = Object.freeze({
27
+ post: (world: W, path: string) => parse(abi().og_load_post(world.id, path)),
28
+ });
package/src/trace.ts ADDED
@@ -0,0 +1,169 @@
1
+ // The run's trace in the Chrome trace format (the Trace Event Format's JSON
2
+ // object form, which Perfetto and chrome://tracing open), the same document
3
+ // on both hosts: native `og run --trace` (src/node/run.ts) and the web
4
+ // Worker (src/web/worker.ts, read through the shell's `trace()`).
5
+ //
6
+ // The engine records every span where it ran and hands them over as Chrome
7
+ // trace events on its own clock (crates/og-runtime/src/trace.rs: `{ now,
8
+ // events }`, `ts` in µs); a take is placed on the document's origin (Unix
9
+ // milliseconds) by the difference between the host's time and the engine's
10
+ // clock read together. Threads of process 1: 1 engine, 2 render, 3 load,
11
+ // 4 cook (og's, natively), 5 gpu, 16 + n og-lanes' pool thread n, 48 + n
12
+ // the cook's thread n; threads that appear mid-run arrive with their names.
13
+
14
+ export type TraceEvent = Record<string, unknown> & { ph: string; pid: number; tid: number; name: string; ts?: number; dur?: number };
15
+
16
+ /** A Chrome trace document as written to a file. */
17
+ export type TraceDocument = {
18
+ traceEvents: TraceEvent[];
19
+ displayTimeUnit: "ms";
20
+ otherData: { originUnixMs: number; producer: string; [k: string]: unknown };
21
+ };
22
+
23
+ /** The fixed threads and their names. */
24
+ export const TRACE_THREADS: readonly (readonly [number, string])[] = [
25
+ [1, "engine"],
26
+ [2, "render"],
27
+ [3, "load"],
28
+ [4, "cook"],
29
+ [5, "gpu"],
30
+ ];
31
+
32
+ /** The process and fixed threads' metadata events. */
33
+ export function traceMetadata(process: string): TraceEvent[] {
34
+ const out: TraceEvent[] = [{ name: "process_name", ph: "M", pid: 1, tid: 0, args: { name: process } }];
35
+ for (const [tid, name] of TRACE_THREADS) {
36
+ out.push({ name: "thread_name", ph: "M", pid: 1, tid, args: { name } });
37
+ out.push({ name: "thread_sort_index", ph: "M", pid: 1, tid, args: { sort_index: tid } });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Unix milliseconds, to the microsecond where the clock has it. */
43
+ export function unixMs(): number {
44
+ return performance.timeOrigin + performance.now();
45
+ }
46
+
47
+ /** A trace being recorded: takes appended as they come, the document at the end. */
48
+ export class Trace {
49
+ readonly events: TraceEvent[];
50
+ readonly origin: number;
51
+ readonly producer: string;
52
+ /** Thread names already in the document (a take names a thread once per engine trace; a document may hold several). */
53
+ private named = new Set<number>();
54
+ /** µs from the engine's clock to the document's origin, set by the first take. */
55
+ private shift: number | undefined;
56
+
57
+ /** A new document from `origin` (Unix ms), or one continued from `prior` (og's prelude: the cook). */
58
+ constructor(opts: { origin?: number; prior?: Partial<TraceDocument>; producer: string; process?: string }) {
59
+ this.producer = opts.producer;
60
+ if (opts.prior?.traceEvents) {
61
+ this.events = [...opts.prior.traceEvents];
62
+ this.origin = opts.prior.otherData?.originUnixMs ?? opts.origin ?? unixMs();
63
+ } else {
64
+ this.events = traceMetadata(opts.process ?? opts.producer);
65
+ this.origin = opts.origin ?? unixMs();
66
+ }
67
+ for (const e of this.events) if (e.ph === "M" && e.name === "thread_name") this.named.add(e.tid);
68
+ }
69
+
70
+ /**
71
+ * Places one take of the engine (`og_*_trace_take`'s JSON) on the
72
+ * document's origin: `hostNow` is the host's Unix ms read beside the take.
73
+ */
74
+ append(taken: string | { now: number; events: TraceEvent[] }, hostNow: number = unixMs()): void {
75
+ const t = typeof taken === "string" ? (JSON.parse(taken) as { now: number; events: TraceEvent[] }) : taken;
76
+ // One shift for the whole trace, from the first take: both clocks are
77
+ // monotonic at the same rate, and a shift read again at every take
78
+ // would move spans that abut across two takes by its jitter (tens of
79
+ // µs), so that they overlap.
80
+ this.shift ??= Math.round((hostNow - t.now - this.origin) * 1000);
81
+ const shift = this.shift;
82
+ for (const e of t.events) {
83
+ if (e.ph === "M") {
84
+ if (e.name === "thread_name") {
85
+ if (this.named.has(e.tid)) continue;
86
+ this.named.add(e.tid);
87
+ }
88
+ this.events.push(e);
89
+ continue;
90
+ }
91
+ this.events.push({ ...e, ts: (e.ts ?? 0) + shift });
92
+ }
93
+ }
94
+
95
+ document(extra: Record<string, unknown> = {}): TraceDocument {
96
+ return { traceEvents: this.events, displayTimeUnit: "ms", otherData: { originUnixMs: this.origin, producer: this.producer, ...extra } };
97
+ }
98
+ }
99
+
100
+ /** What {@link checkTrace} found. */
101
+ export type TraceCheck = { errors: string[]; spans: number; threads: Record<number, string> };
102
+
103
+ /**
104
+ * Holds a document to the format as Perfetto's importer reads it, and to
105
+ * what this engine's traces promise: `traceEvents` a list; every `X` event
106
+ * a name, integer `ts`, `dur` ≥ 0 and integer `pid`/`tid`; metadata with
107
+ * `args.name`; every span on a thread nested inside the one it starts in
108
+ * (never overlapping it); and spans that lie where they ran. A thread whose
109
+ * children are laid end to end from their parent's start in every parent
110
+ * (the first child at the parent's `ts`, each next one where the previous
111
+ * ended), over at least `endToEndAfter` parents with two or more children,
112
+ * is reported: recorded starts leave gaps somewhere. The GPU's passes
113
+ * (`cat` "gpu") are exempt: one timestamp ends a pass and begins the next,
114
+ * so they are back to back by the measurement's construction.
115
+ */
116
+ export function checkTrace(doc: unknown, endToEndAfter = 8): TraceCheck {
117
+ const errors: string[] = [];
118
+ const threads: Record<number, string> = {};
119
+ const d = doc as Partial<TraceDocument> | null;
120
+ if (!d || !Array.isArray(d.traceEvents)) return { errors: ["traceEvents is not a list"], spans: 0, threads };
121
+ const byTid = new Map<number, { ts: number; dur: number; name: string }[]>();
122
+ const backToBack = new Set<number>();
123
+ for (const e of d.traceEvents) {
124
+ if (!Number.isInteger(e.pid) || !Number.isInteger(e.tid)) errors.push(`${e.name}: pid/tid not integers`);
125
+ if (e.ph === "M") {
126
+ const name = (e.args as { name?: unknown } | undefined)?.name;
127
+ if ((e.name === "thread_name" || e.name === "process_name") && typeof name !== "string") errors.push(`${e.name} metadata without args.name`);
128
+ if (e.name === "thread_name" && typeof name === "string") threads[e.tid] = name;
129
+ continue;
130
+ }
131
+ if (e.ph !== "X") continue;
132
+ if (typeof e.name !== "string" || !Number.isInteger(e.ts) || !Number.isInteger(e.dur) || (e.dur as number) < 0) {
133
+ errors.push(`malformed span ${JSON.stringify(e).slice(0, 120)}`);
134
+ continue;
135
+ }
136
+ let list = byTid.get(e.tid);
137
+ if (!list) byTid.set(e.tid, (list = []));
138
+ list.push({ ts: e.ts as number, dur: e.dur as number, name: e.name });
139
+ if (e.cat === "gpu") backToBack.add(e.tid);
140
+ }
141
+ let spans = 0;
142
+ for (const [tid, list] of byTid) {
143
+ if (!(tid in threads)) errors.push(`thread ${tid} has spans and no name`);
144
+ spans += list.length;
145
+ // Parents first: earlier start, then the longer one.
146
+ list.sort((a, b) => a.ts - b.ts || b.dur - a.dur);
147
+ const stack: { ts: number; end: number; name: string; children: { ts: number; end: number }[] }[] = [];
148
+ const closed: { ts: number; children: { ts: number; end: number }[] }[] = [];
149
+ const close = () => closed.push(stack.pop()!);
150
+ for (const s of list) {
151
+ const end = s.ts + s.dur;
152
+ while (stack.length && stack[stack.length - 1]!.end <= s.ts) close();
153
+ const top = stack[stack.length - 1];
154
+ if (top && end > top.end) {
155
+ errors.push(`thread ${tid}: ${s.name} [${s.ts}, ${end}] overlaps ${top.name} [${top.ts}, ${top.end}] without nesting`);
156
+ continue;
157
+ }
158
+ top?.children.push({ ts: s.ts, end });
159
+ stack.push({ ts: s.ts, end, name: s.name, children: [] });
160
+ }
161
+ while (stack.length) close();
162
+ const parents = closed.filter((p) => p.children.length >= 2);
163
+ const laid = parents.filter((p) => p.children[0]!.ts === p.ts && p.children.every((c, i) => i === 0 || c.ts === p.children[i - 1]!.end));
164
+ if (!backToBack.has(tid) && parents.length >= endToEndAfter && laid.length === parents.length) {
165
+ errors.push(`thread ${tid} (${threads[tid] ?? "?"}): the children of all ${parents.length} parents are laid end to end from the parent's start: starts were derived, not recorded`);
166
+ }
167
+ }
168
+ return { errors, spans, threads };
169
+ }