@origingame/engine 0.2.0 → 0.4.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 (118) hide show
  1. package/bin/og.mjs +1 -1
  2. package/dist/web/origin-engine.d.ts +255 -5
  3. package/dist/web/origin-engine.js +1584 -125
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +80 -3
  6. package/dist/web-publish/origin-engine-lanes.js +1288 -82
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +199 -6
  9. package/dist/web-publish/origin-engine.js +1285 -79
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +65 -4
  12. package/dist/web-publish-2d/origin-engine-lanes.js +1285 -79
  13. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  14. package/dist/web-publish-2d/origin-engine.d.ts +198 -5
  15. package/dist/web-publish-2d/origin-engine.js +1282 -76
  16. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +64 -3
  18. package/knowledge/README.md +2 -1
  19. package/knowledge/guides/three-to-og.md +393 -0
  20. package/knowledge/knowledge.json +2 -1
  21. package/knowledge/looks/catalogue.json +40 -40
  22. package/knowledge/package.json +3 -2
  23. package/knowledge/scaffold/distribution.json +14 -0
  24. package/knowledge/scaffold/origin-engine.json +2 -2
  25. package/knowledge/scaffold/template/README.md +2 -1
  26. package/knowledge/scaffold/template/assets/audio/sting.ogt +10 -0
  27. package/knowledge/scaffold/template/assets/timelines/intro.ogt +101 -0
  28. package/knowledge/scaffold/template/assets/timelines/narration.srt +11 -0
  29. package/knowledge/scaffold/template/host.d.ts +46 -0
  30. package/knowledge/scaffold/template/package.json +1 -1
  31. package/knowledge/scaffold/template/project.ogt +1 -1
  32. package/knowledge/scaffold/template/src/main.ts +1 -1
  33. package/knowledge/scaffold/template/tests/frames.ts +134 -0
  34. package/knowledge/scaffold/template/tests/intro.og.ts +65 -0
  35. package/knowledge/scaffold/template/tests/smoke.og.ts +30 -36
  36. package/knowledge/scaffold/template/tsconfig.json +1 -1
  37. package/knowledge/scripts/mount.ts +2 -1
  38. package/knowledge/scripts/validate.ts +159 -31
  39. package/knowledge/skills/og-audio/SKILL.md +2 -2
  40. package/knowledge/skills/og-build/SKILL.md +29 -7
  41. package/knowledge/skills/og-core/SKILL.md +31 -5
  42. package/knowledge/skills/og-feel/SKILL.md +2 -2
  43. package/knowledge/skills/og-game/SKILL.md +1331 -16
  44. package/knowledge/skills/og-i18n/SKILL.md +7 -7
  45. package/knowledge/skills/og-model/SKILL.md +374 -0
  46. package/knowledge/skills/og-platform-sdk/SKILL.md +2 -1
  47. package/knowledge/skills/og-taste/SKILL.md +82 -73
  48. package/knowledge/skills/og-ui/SKILL.md +80 -58
  49. package/knowledge/wiki/ai.md +20 -20
  50. package/knowledge/wiki/anim.md +392 -41
  51. package/knowledge/wiki/atmosphere.md +36 -13
  52. package/knowledge/wiki/audio.md +18 -18
  53. package/knowledge/wiki/camera.md +19 -13
  54. package/knowledge/wiki/commands.md +19 -13
  55. package/knowledge/wiki/debug.md +414 -0
  56. package/knowledge/wiki/game.md +8 -8
  57. package/knowledge/wiki/i18n.md +7 -7
  58. package/knowledge/wiki/index.md +317 -8
  59. package/knowledge/wiki/input.md +47 -47
  60. package/knowledge/wiki/load.md +238 -70
  61. package/knowledge/wiki/math.md +2607 -0
  62. package/knowledge/wiki/nav.md +34 -34
  63. package/knowledge/wiki/net.md +340 -0
  64. package/knowledge/wiki/path.md +402 -0
  65. package/knowledge/wiki/physics.md +95 -95
  66. package/knowledge/wiki/platform.md +463 -29
  67. package/knowledge/wiki/probe.md +681 -67
  68. package/knowledge/wiki/reflect.md +29 -17
  69. package/knowledge/wiki/render.md +4993 -1998
  70. package/knowledge/wiki/rng.md +14 -14
  71. package/knowledge/wiki/shapes.md +727 -0
  72. package/knowledge/wiki/stream.md +15 -15
  73. package/knowledge/wiki/system.md +8 -8
  74. package/knowledge/wiki/systems.md +2 -2
  75. package/knowledge/wiki/terrain.md +24 -20
  76. package/knowledge/wiki/tilemap.md +13 -13
  77. package/knowledge/wiki/time.md +4 -4
  78. package/knowledge/wiki/timeline.md +396 -0
  79. package/knowledge/wiki/transform.md +8 -8
  80. package/knowledge/wiki/tween.md +17 -17
  81. package/knowledge/wiki/ui.md +560 -654
  82. package/knowledge/wiki/video.md +157 -0
  83. package/knowledge/wiki/world.md +36 -36
  84. package/package.json +4 -3
  85. package/schema/api.schema.json +28151 -19640
  86. package/shell/page.ts +26 -2
  87. package/shell/platform.ts +51 -0
  88. package/shell/player.ts +15 -2
  89. package/shell/transport.ts +6 -0
  90. package/src/depth.ts +71 -0
  91. package/src/e6.ts +32 -1
  92. package/src/env.d.ts +5 -1
  93. package/src/generated/abi.ts +226 -4
  94. package/src/geom.ts +260 -0
  95. package/src/index.ts +32 -4
  96. package/src/light.ts +18 -0
  97. package/src/math.ts +622 -0
  98. package/src/node/index.ts +17 -0
  99. package/src/node/playtest-run.ts +115 -18
  100. package/src/node/polish.ts +13 -3
  101. package/src/node/render.ts +291 -0
  102. package/src/node/run.ts +86 -5
  103. package/src/p2.ts +19 -1
  104. package/src/platform-local.ts +2 -0
  105. package/src/playtest.ts +65 -4
  106. package/src/runtime.ts +100 -9
  107. package/src/targets.ts +28 -0
  108. package/src/trace.ts +169 -0
  109. package/src/ui-kit/huds.ts +34 -34
  110. package/src/ui-kit/menus.ts +12 -12
  111. package/src/ui-kit/settings.ts +11 -2
  112. package/src/ui-kit/store.ts +18 -18
  113. package/src/ui-kit/story.ts +6 -4
  114. package/src/ui-kit/util.ts +8 -8
  115. package/src/web/shell.ts +59 -1
  116. package/src/web/worker.ts +105 -4
  117. package/types/bindings.json +9368 -6619
  118. package/types/index.d.ts +16377 -11520
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
+ });
@@ -28,6 +28,8 @@ export interface PlatformHost {
28
28
  /** `player`, `local` or `none` (og.platform.info). */
29
29
  readonly host: string;
30
30
  readonly capabilities: readonly string[];
31
+ /** The Player hello's UTC milliseconds, sampled when this host attaches. */
32
+ readonly time?: number | undefined;
31
33
  /** Handle calls in order; answer every one but `telemetry`, now or later. */
32
34
  handle(calls: readonly PlatformCall[], answer: (request: number, result: PlatformAnswer) => void): void;
33
35
  }
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. */
@@ -91,6 +127,14 @@ export interface PlaytestDesc {
91
127
  readonly seed?: number;
92
128
  /** The render target, `WIDTHxHEIGHT` (default 1280x720). */
93
129
  readonly size?: string;
130
+ /**
131
+ * `action[:args];...` run once through the command channel after `start`, before the first
132
+ * tick (`og run --setup`), in every process of the playtest: the stand-ins for what a host does
133
+ * around the game, such as `cover:12,12,40,40`, the published page's Back over the play surface
134
+ * (`Viewport.covered`), so the frames lay out as the page's. Not part of `commands`: the
135
+ * published page plays a script without it and covers itself.
136
+ */
137
+ readonly setup?: string;
94
138
  /** Run the script this many times in fresh processes (default 1) and report every end hash in `run.hashes`: a determinism check. The files are the first run's. */
95
139
  readonly runs?: number;
96
140
  /**
@@ -106,9 +150,25 @@ export interface PlaytestDesc {
106
150
  * process has its own store in memory.
107
151
  */
108
152
  readonly store?: "keep";
109
- /** More processes after the script, in order, each a fresh `og run` (a relaunch): their runs are `run.then`. */
153
+ /**
154
+ * Play the script in a room (docs/design.md §2.8, §2.10): the playtest starts a relay
155
+ * (`og relay`, in its own process) and runs `players` processes at once, each `og run
156
+ * --room ws://127.0.0.1:P/<name>` with its own script (`room.commands[i]`, else `commands`)
157
+ * and its own files. The first is the run; the others are `run.peers`. Every process stops
158
+ * at `room.ticks`, or at its script's `quit`, which lands on one tick of the room for all.
159
+ * `runs` is refused with it; each `then` step relaunches every process at once into a room of
160
+ * its own (`<name>.then-<n>`), each running the step's script, its runs in `run.then[n].peers`;
161
+ * `store: "keep"` keeps one store per place across them.
162
+ */
163
+ readonly room?: PlaytestRoom;
164
+ /** 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
165
  readonly then?: readonly PlaytestStep[];
111
- readonly check: (run: PlaytestRun, t: PlaytestChecks) => void | Promise<void>;
166
+ /**
167
+ * Checks over what the run wrote. Optional when the script carries `expect:` steps: each
168
+ * expectation is a check (a failed one fails the playtest with its tick and observed value,
169
+ * a frame one without a renderer is unchecked), so a playtest can be its script alone.
170
+ */
171
+ readonly check?: (run: PlaytestRun, t: PlaytestChecks) => void | Promise<void>;
112
172
  }
113
173
 
114
174
  /** A later process of a playtest (`PlaytestDesc.then`). */
@@ -124,8 +184,9 @@ export const PLAYTEST_CLOCK = 1767225600000;
124
184
 
125
185
  /** Declare a playtest: the default export of a `tests/*.og.ts` file. */
126
186
  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");
187
+ const expects = /(^|;)\s*[\d.]+:expect:/.test(desc.commands ?? "");
188
+ if (!desc.name || !desc.commands || (typeof desc.check !== "function" && !expects)) {
189
+ throw new Error("playtest({ name, commands, check }) needs all three (check may be left out when the commands carry expect: steps)");
129
190
  }
130
191
  return Object.freeze({ ...desc });
131
192
  }
package/src/runtime.ts CHANGED
@@ -42,6 +42,8 @@ export interface ColumnSpec {
42
42
  readonly stride: number;
43
43
  /** The stride is each field's capacity (`str(32)`), not the kind's. */
44
44
  readonly capacity?: boolean;
45
+ /** The list factory (`list(u32(), 8)`): no kind of its own, a list field's kind is its element's. */
46
+ readonly list?: boolean;
45
47
  }
46
48
 
47
49
  export const COLUMNS: ReadonlyMap<string, ColumnSpec> = new Map(
@@ -122,6 +124,8 @@ export interface FieldSpec {
122
124
  readonly options: Record<string, unknown>;
123
125
  /** A `str` field's capacity in bytes. */
124
126
  readonly capacity?: number;
127
+ /** A list field's capacity in elements (`list(u32(), 8)`); `kind` is the element's. */
128
+ readonly list?: number;
125
129
  }
126
130
 
127
131
  export interface Token {
@@ -138,6 +142,7 @@ function fieldList(fields: Readonly<Record<string, FieldSpec>>): unknown[] {
138
142
  default: spec?.default,
139
143
  options: spec?.options ?? {},
140
144
  ...(spec?.capacity === undefined ? {} : { capacity: spec.capacity }),
145
+ ...(spec?.list === undefined ? {} : { list: spec.list }),
141
146
  }));
142
147
  }
143
148
 
@@ -150,13 +155,18 @@ export function defineRecord(kind: "component" | "resource", name: string, field
150
155
  return token;
151
156
  }
152
157
 
153
- function builtinFields(fields: ReadonlyArray<{ name: string; column?: string; type?: string; default?: unknown }>): Record<string, FieldSpec> {
158
+ type DeclField = { name: string; column?: string; type?: string; default?: unknown; list?: number };
159
+
160
+ function builtinFields(fields: ReadonlyArray<DeclField>): Record<string, FieldSpec> {
154
161
  const out: Record<string, FieldSpec> = {};
155
- for (const f of fields) out[f.name] = Object.freeze({ kind: f.column ?? f.type ?? "", default: f.default, options: Object.freeze({}) });
162
+ for (const f of fields) {
163
+ const spec = { kind: f.column ?? f.type ?? "", default: f.default, options: Object.freeze({}) };
164
+ out[f.name] = Object.freeze(f.list === undefined ? spec : { ...spec, list: f.list });
165
+ }
156
166
  return out;
157
167
  }
158
168
 
159
- type Decl = { name: string; schema: string; fields: Array<{ name: string; column?: string; type?: string; default?: unknown }> };
169
+ type Decl = { name: string; schema: string; fields: Array<DeclField> };
160
170
 
161
171
  /** The engine-owned tokens on `og`, under their schema names (`og.Transform`). */
162
172
  export const BUILTINS: Readonly<Record<string, Token>> = Object.freeze(
@@ -316,7 +326,9 @@ function layoutOf(component: Token): { names: string[]; kinds: ColumnSpec[] } {
316
326
  const kind = COLUMNS.get(field.kind);
317
327
  if (!kind) throw new Error(`'${component.name}.${n}' is not a column field`);
318
328
  // A str column's stride is the field's capacity: one byte lane per byte.
319
- return kind.capacity ? { ...kind, stride: field.capacity ?? 0 } : kind;
329
+ if (kind.capacity) return { ...kind, stride: field.capacity ?? 0 };
330
+ // A list's is its element's, once for the count and once per element.
331
+ return field.list === undefined ? kind : { ...kind, stride: kind.stride * (field.list + 1) };
320
332
  });
321
333
  layouts.set(component, (l = { names, kinds }));
322
334
  }
@@ -483,6 +495,9 @@ interface RecordField {
483
495
  readonly size: number;
484
496
  readonly readonly: boolean;
485
497
  readonly variants?: readonly string[];
498
+ /** A list's element kind (`f32`, `f64`, `u32`, `i32`, `handle`) and capacity in elements. */
499
+ readonly element?: string;
500
+ readonly capacity?: number;
486
501
  }
487
502
 
488
503
  interface RecordLayout {
@@ -599,9 +614,55 @@ const LANES: Readonly<Record<string, readonly string[]>> = {
599
614
  Color: ["r", "g", "b"],
600
615
  };
601
616
 
617
+ /** Bytes of one lane of a list's element kind. */
618
+ const LANE_SIZE: Readonly<Record<string, number>> = { f32: 4, f64: 8, u32: 4, i32: 4, handle: 8 };
619
+
620
+ function readLane(v: DataView, o: number, element: string): number {
621
+ switch (element) {
622
+ case "f32": return v.getFloat32(o, true);
623
+ case "f64": return v.getFloat64(o, true);
624
+ case "u32": return v.getUint32(o, true);
625
+ case "i32": return v.getInt32(o, true);
626
+ default: return v.getUint32(o, true) + v.getUint32(o + 4, true) * 4294967296;
627
+ }
628
+ }
629
+
630
+ function writeLane(v: DataView, o: number, element: string, n: number): void {
631
+ switch (element) {
632
+ case "f32": return v.setFloat32(o, n, true);
633
+ case "f64": return v.setFloat64(o, n, true);
634
+ case "u32": return v.setUint32(o, n, true);
635
+ case "i32": return v.setInt32(o, n, true);
636
+ default:
637
+ v.setUint32(o, n % 4294967296, true);
638
+ v.setUint32(o + 4, Math.floor(n / 4294967296), true);
639
+ }
640
+ }
641
+
642
+ /** A list element checked as the engine's JSON path checks it; throws what it refuses. */
643
+ function listElement(value: unknown, element: string, i: number): number {
644
+ const n = number(value, `element ${i}`);
645
+ const whole = (lo: number, hi: number, what: string) => {
646
+ if (!Number.isInteger(n) || n < lo || n > hi) throw new Error(`element ${i}: ${n} is not ${what}`);
647
+ };
648
+ if (element === "u32") whole(0, 4294967295, "a u32");
649
+ else if (element === "i32") whole(-2147483648, 2147483647, "an i32");
650
+ else if (element === "handle") whole(0, 9007199254740991, "a handle");
651
+ return n;
652
+ }
653
+
602
654
  function decodeField(v: DataView, bytes: Uint8Array, f: RecordField): unknown {
603
655
  const o = f.offset;
604
656
  switch (f.kind) {
657
+ case "list": {
658
+ // A frozen copy: `push` throws instead of changing nothing.
659
+ const element = f.element ?? "f32";
660
+ const lane = LANE_SIZE[element] ?? 4;
661
+ const count = Math.min(readLane(v, o, element), f.capacity ?? 0);
662
+ const out: number[] = [];
663
+ for (let i = 0; i < count; i++) out.push(readLane(v, o + lane * (i + 1), element));
664
+ return Object.freeze(out);
665
+ }
605
666
  case "f32": return v.getFloat32(o, true);
606
667
  case "f64": return v.getFloat64(o, true);
607
668
  case "u32": return v.getUint32(o, true);
@@ -639,6 +700,23 @@ function number(value: unknown, what: string): number {
639
700
  function encodeField(v: DataView, bytes: Uint8Array, f: RecordField, value: unknown): void {
640
701
  const o = f.offset;
641
702
  switch (f.kind) {
703
+ case "list": {
704
+ // The whole list: an array, at most the capacity, every element checked before a byte moves.
705
+ const element = f.element ?? "f32";
706
+ const capacity = f.capacity ?? 0;
707
+ const items = value === null || value === undefined ? [] : value;
708
+ if (!Array.isArray(items)) throw new Error(`a list is written whole, as an array, got ${JSON.stringify(value)}`);
709
+ if (items.length > capacity) {
710
+ const kind = element === "handle" ? "Asset" : element;
711
+ throw new Error(`${items.length} elements and the field holds ${capacity}: remove ${items.length - capacity}, or declare the field list(${kind}(), ${items.length}) or larger`);
712
+ }
713
+ const lanes = items.map((x, i) => listElement(x, element, i));
714
+ const lane = LANE_SIZE[element] ?? 4;
715
+ bytes.fill(0, o, o + f.size);
716
+ writeLane(v, o, element, lanes.length);
717
+ lanes.forEach((n, i) => writeLane(v, o + lane * (i + 1), element, n));
718
+ return;
719
+ }
642
720
  case "f32": return v.setFloat32(o, number(value, "the value"), true);
643
721
  case "f64": return v.setFloat64(o, number(value, "the value"), true);
644
722
  case "u32": {
@@ -732,7 +810,9 @@ function recordHandle(world: WorldImpl, r: RecordCopy): object {
732
810
  if (!refresh(r)) {
733
811
  const answer = parse<Result<Record<string, unknown>>>(host!.abi.og_world_resource(id, name));
734
812
  if (!answer.ok) throw new Error(`${name}: ${answer.reason}`);
735
- return (lastValue = answer.value[key]);
813
+ const value = answer.value[key];
814
+ // A list reads as a frozen copy on this path too.
815
+ return (lastValue = Array.isArray(value) ? Object.freeze(value) : value);
736
816
  }
737
817
  }
738
818
  return (lastValue = decodeField(r.view, r.bytes, field()));
@@ -1122,7 +1202,7 @@ export class Engine {
1122
1202
  /** Attach what answers og.platform's calls: the native host's local store, the web shell's Player bridge. */
1123
1203
  attachPlatform(host: PlatformHost): void {
1124
1204
  this.#platform = host;
1125
- abi().og_platform_set_host(this.id, JSON.stringify({ host: host.host, capabilities: host.capabilities, time: this.#clock.now(), clock: this.#clock.kind }));
1205
+ abi().og_platform_set_host(this.id, JSON.stringify({ host: host.host, capabilities: host.capabilities, time: host.time ?? this.#clock.now(), clock: host.time === undefined ? this.#clock.kind : "player" }));
1126
1206
  }
1127
1207
 
1128
1208
  /** Set the clock og.platform tells the game (before attachPlatform, which tells it the time). */
@@ -1311,6 +1391,10 @@ function makeProbe(id: number) {
1311
1391
  commands: (script: string) => parse(a().og_engine_probe_commands(id, script)),
1312
1392
  listen: (seconds?: number) => parse(a().og_engine_probe_listen(id, seconds)),
1313
1393
  memory: () => parse(a().og_engine_probe_memory(id)),
1394
+ describe: () => parse(a().og_engine_probe_describe(id)),
1395
+ expect: (what: string) => parse(a().og_engine_probe_expect(id, what)),
1396
+ expectations: () => parse(a().og_engine_probe_expectations(id)),
1397
+ saveScene: (write?: boolean, spawned?: boolean) => parse(a().og_engine_probe_save_scene(id, write, spawned)),
1314
1398
  });
1315
1399
  }
1316
1400
 
@@ -1331,6 +1415,7 @@ export const render = Object.freeze({
1331
1415
  parse(abi().og_render_update_material(world.id, material, json(desc))),
1332
1416
  capabilities: (world: WorldImpl) => parse(abi().og_render_capabilities(world.id)),
1333
1417
  applyLook: (world: WorldImpl, look: number) => parse(abi().og_render_apply_look(world.id, look)),
1418
+ applyQuality: (world: WorldImpl, preset: string) => parse(abi().og_render_apply_quality(world.id, preset)),
1334
1419
  look: (world: WorldImpl) => parse(abi().og_render_look(world.id)),
1335
1420
  text: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_text(world.id, json(desc))),
1336
1421
  sprite: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_sprite(world.id, json(desc))),
@@ -1363,17 +1448,21 @@ export const load = Object.freeze({
1363
1448
  environment: (world: WorldImpl, path: string) => parse(abi().og_load_environment(world.id, path)),
1364
1449
  emitter: (world: WorldImpl, path: string) => parse(abi().og_load_emitter(world.id, path)),
1365
1450
  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)),
1451
+ style: (world: WorldImpl, path: string) => parse(abi().og_load_style(world.id, path)),
1367
1452
  look: (world: WorldImpl, path: string) => parse(abi().og_load_look(world.id, path)),
1368
1453
  lookInfo: (world: WorldImpl, look: number) => parse(abi().og_load_look_info(world.id, look)),
1369
1454
  palette: (world: WorldImpl, path: string) => parse(abi().og_load_palette(world.id, path)),
1370
1455
  soundInfo: (world: WorldImpl, sound: number) => parse(abi().og_load_sound_info(world.id, sound)),
1456
+ video: (world: WorldImpl, path: string) => parse(abi().og_load_video(world.id, path)),
1457
+ videoInfo: (world: WorldImpl, video: number) => parse(abi().og_load_video_info(world.id, video)),
1371
1458
  status: (world: WorldImpl, asset: number) => abi().og_load_status(world.id, asset),
1372
1459
  error: (world: WorldImpl, asset: number) => parse(abi().og_load_error(world.id, asset)),
1373
1460
  gltfInfo: (world: WorldImpl, asset: number) => parse(abi().og_load_gltf_info(world.id, asset)),
1374
1461
  animation: (world: WorldImpl, path: string) => parse(abi().og_load_animation(world.id, path)),
1462
+ timeline: (world: WorldImpl, path: string) => parse(abi().og_load_timeline(world.id, path)),
1375
1463
  flipbook: (world: WorldImpl, path: string) => parse(abi().og_load_flipbook(world.id, path)),
1376
1464
  rig: (world: WorldImpl, path: string) => parse(abi().og_load_rig(world.id, path)),
1465
+ rig2d: (world: WorldImpl, path: string) => parse(abi().og_load_rig2d(world.id, path)),
1377
1466
  tilemap: (world: WorldImpl, path: string) => parse(abi().og_load_tilemap(world.id, path)),
1378
1467
  });
1379
1468
 
@@ -1381,8 +1470,10 @@ export const anim = Object.freeze({
1381
1470
  graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
1382
1471
  humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
1383
1472
  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).
1473
+ rig2dInfo: (world: WorldImpl, rig: number) => parse(abi().og_anim_rig2d_info(world.id, rig)),
1474
+ skeleton2dBoxes: (world: WorldImpl, entity: number) => parse(abi().og_anim_skeleton2d_boxes(world.id, entity)),
1475
+ // One lookup for a flipbook's or a 2D rig's clips (an index, -1 when
1476
+ // missing) and a loaded model's (og.load.animation: a ClipAsset, 0 when missing).
1386
1477
  clip: (info: { clips?: ReadonlyArray<{ clip: number; name: string }>; skeleton?: number }, name: string): number =>
1387
1478
  info?.clips?.find((c) => c.name === name)?.clip ?? (info?.skeleton !== undefined ? 0 : -1),
1388
1479
  });
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
+ });