@origingame/engine 0.1.1 → 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 (109) 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 +4 -4
  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 +21 -11
  36. package/knowledge/skills/og-build/SKILL.md +25 -6
  37. package/knowledge/skills/og-core/SKILL.md +11 -0
  38. package/knowledge/skills/og-feel/SKILL.md +8 -1
  39. package/knowledge/skills/og-game/SKILL.md +994 -21
  40. package/knowledge/skills/og-i18n/SKILL.md +12 -3
  41. package/knowledge/skills/og-platform-sdk/SKILL.md +4 -1
  42. package/knowledge/skills/og-taste/SKILL.md +82 -73
  43. package/knowledge/skills/og-ui/SKILL.md +82 -54
  44. package/knowledge/wiki/ai.md +20 -20
  45. package/knowledge/wiki/anim.md +384 -41
  46. package/knowledge/wiki/atmosphere.md +36 -13
  47. package/knowledge/wiki/audio.md +18 -18
  48. package/knowledge/wiki/camera.md +19 -13
  49. package/knowledge/wiki/commands.md +19 -13
  50. package/knowledge/wiki/debug.md +414 -0
  51. package/knowledge/wiki/game.md +8 -8
  52. package/knowledge/wiki/i18n.md +7 -7
  53. package/knowledge/wiki/index.md +265 -7
  54. package/knowledge/wiki/input.md +47 -47
  55. package/knowledge/wiki/load.md +236 -68
  56. package/knowledge/wiki/math.md +2614 -7
  57. package/knowledge/wiki/nav.md +34 -34
  58. package/knowledge/wiki/net.md +340 -0
  59. package/knowledge/wiki/path.md +402 -0
  60. package/knowledge/wiki/physics.md +124 -106
  61. package/knowledge/wiki/platform.md +465 -31
  62. package/knowledge/wiki/probe.md +681 -67
  63. package/knowledge/wiki/reflect.md +15 -15
  64. package/knowledge/wiki/render.md +3904 -1897
  65. package/knowledge/wiki/rng.md +14 -14
  66. package/knowledge/wiki/stream.md +15 -15
  67. package/knowledge/wiki/system.md +8 -8
  68. package/knowledge/wiki/systems.md +2 -2
  69. package/knowledge/wiki/terrain.md +19 -19
  70. package/knowledge/wiki/tilemap.md +19 -15
  71. package/knowledge/wiki/time.md +4 -4
  72. package/knowledge/wiki/timeline.md +396 -0
  73. package/knowledge/wiki/transform.md +8 -8
  74. package/knowledge/wiki/tween.md +17 -17
  75. package/knowledge/wiki/ui.md +603 -629
  76. package/knowledge/wiki/video.md +157 -0
  77. package/knowledge/wiki/world.md +67 -37
  78. package/package.json +7 -6
  79. package/schema/api.schema.json +21071 -13964
  80. package/shell/page.ts +26 -2
  81. package/shell/platform.ts +46 -0
  82. package/shell/player.ts +7 -0
  83. package/src/depth.ts +71 -0
  84. package/src/e6.ts +32 -1
  85. package/src/env.d.ts +5 -1
  86. package/src/generated/abi.ts +189 -3
  87. package/src/index.ts +26 -4
  88. package/src/light.ts +18 -0
  89. package/src/math.ts +622 -0
  90. package/src/node/index.ts +17 -0
  91. package/src/node/playtest-run.ts +114 -18
  92. package/src/node/polish.ts +15 -4
  93. package/src/node/render.ts +291 -0
  94. package/src/node/run.ts +91 -6
  95. package/src/p2.ts +19 -1
  96. package/src/playtest.ts +57 -4
  97. package/src/runtime.ts +15 -3
  98. package/src/targets.ts +28 -0
  99. package/src/trace.ts +169 -0
  100. package/src/ui-kit/huds.ts +49 -45
  101. package/src/ui-kit/menus.ts +12 -12
  102. package/src/ui-kit/settings.ts +2 -2
  103. package/src/ui-kit/store.ts +18 -18
  104. package/src/ui-kit/story.ts +6 -4
  105. package/src/ui-kit/util.ts +8 -8
  106. package/src/web/shell.ts +58 -0
  107. package/src/web/worker.ts +103 -2
  108. package/types/bindings.json +8945 -6456
  109. package/types/index.d.ts +15507 -11437
@@ -16,10 +16,12 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
16
16
  import { basename, dirname, join, resolve } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { bundleGame } from "./build.ts";
19
- import { FrameUnchecked, PLAYTEST_CLOCK, type PlaytestChecks, type PlaytestDesc, type PlaytestRun } from "../playtest.ts";
19
+ import { FrameUnchecked, PLAYTEST_CLOCK, type PlaytestChecks, type PlaytestDesc, type PlaytestLockstep, type PlaytestRun } from "../playtest.ts";
20
+ import { loadNativeHost, native } from "./index.ts";
20
21
 
21
22
  declare const Bun: {
22
23
  spawnSync(cmd: string[], o?: { cwd?: string; stdout?: string; stderr?: string }): { exitCode: number; stdout: Uint8Array; stderr: Uint8Array };
24
+ spawn(cmd: string[], o?: { stdout?: string; stderr?: string }): { exited: Promise<number>; stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array> };
23
25
  };
24
26
 
25
27
  const argv = process.argv.slice(2);
@@ -45,7 +47,7 @@ mkdirSync(out, { recursive: true });
45
47
  const t0 = performance.now();
46
48
  const failures: string[] = [];
47
49
  let checks = 0;
48
- // Checks made inside t.frame (with a renderer): what a run without one leaves out.
50
+ // Checks made inside t.frame and region or frame expectations (with a renderer): what a run without one leaves out.
49
51
  let frameChecks = 0;
50
52
  // Every screenshot taken without a renderer, by the file it would have written, in order.
51
53
  const unchecked: string[] = [];
@@ -84,7 +86,7 @@ if (!b.ok) {
84
86
  finish();
85
87
  }
86
88
  const desc = ((await import(pathToFileURL(bundled).href)) as { default?: PlaytestDesc }).default;
87
- if (!desc || typeof desc.check !== "function") {
89
+ if (!desc || !desc.commands) {
88
90
  failures.push(`${file} has no default export: export default playtest({ name, commands, check })`);
89
91
  finish();
90
92
  }
@@ -94,40 +96,118 @@ result.title = desc!.name;
94
96
  // The host's clock is pinned (PlaytestDesc.clock), so og.platform.now is
95
97
  // reproducible; `store: "keep"` shares one local store file between them,
96
98
  // kept under a dot name, which no step's output may take (an output named
97
- // `platform.json` once replaced it: hollowbell FINDINGS #18).
98
- const store = desc!.store === "keep" ? join(out, ".store", "platform.json") : undefined;
99
- if (store) mkdirSync(dirname(store), { recursive: true });
100
- const command = (commands: string, dir: string, clock: number) => {
99
+ // `platform.json` once replaced it: hollowbell FINDINGS #18). In a room each
100
+ // place has its own (`platform-<place>.json`): its processes run at once,
101
+ // and a `then` step relaunches every place on its own store.
102
+ const storeOf = (place: number): string | undefined => {
103
+ if (desc!.store !== "keep") return undefined;
104
+ const path = join(out, ".store", desc!.room && place > 0 ? `platform-${place}.json` : "platform.json");
105
+ mkdirSync(dirname(path), { recursive: true });
106
+ return path;
107
+ };
108
+ const command = (commands: string, dir: string, clock: number, room?: { url: string; ticks?: number | undefined; place: number }) => {
101
109
  const c = ["bun", join(here, "run.ts"), "--entry", entry!, "--commands", commands, "--out", dir, "--json", "--clock", String(clock)];
110
+ if (room) c.push("--room", room.url);
111
+ if (room?.ticks !== undefined) c.push("--ticks", String(room.ticks));
102
112
  if (root) c.push("--root", root);
103
113
  if (desc!.seed !== undefined) c.push("--seed", String(desc!.seed));
104
114
  if (desc!.size) c.push("--size", desc!.size);
115
+ const store = storeOf(room?.place ?? 0);
105
116
  if (store) c.push("--platform-store", store);
106
117
  return c;
107
118
  };
108
- type Summary = { tick: number; hash: string; entities: number; quit: boolean; tier: string; outputs: string[]; warnings: string[]; unchecked?: string[] };
119
+ type ExpectResult = { tick: number; expect: string; kind: string; status: "pass" | "fail" | "unchecked"; expected: unknown; observed: unknown; reason: string };
120
+ type Summary = {
121
+ tick: number;
122
+ hash: string;
123
+ netHash?: string;
124
+ lockstep?: PlaytestLockstep;
125
+ entities: number;
126
+ quit: boolean;
127
+ tier: string;
128
+ outputs: string[];
129
+ warnings: string[];
130
+ unchecked?: string[];
131
+ expectations?: { failed: number; results: ExpectResult[] };
132
+ };
109
133
  const spawn = (c: string[], what: string): Summary | null => {
110
134
  const r = Bun.spawnSync(c, { stdout: "pipe", stderr: "pipe" });
111
- const stdout = new TextDecoder().decode(r.stdout);
112
- const stderr = new TextDecoder().decode(r.stderr);
135
+ return summarise(r.exitCode, new TextDecoder().decode(r.stdout), new TextDecoder().decode(r.stderr), what);
136
+ };
137
+ function summarise(exitCode: number, stdout: string, stderr: string, what: string): Summary | null {
113
138
  let summary: Summary | null = null;
114
139
  try {
115
140
  summary = JSON.parse(stdout.trim().split("\n").pop() ?? "");
116
141
  } catch {
117
142
  summary = null;
118
143
  }
119
- if (r.exitCode !== 0 || !summary) {
120
- failures.push(`${what} failed (exit ${r.exitCode}): ${stderr.trim() || stdout.trim()}`);
144
+ // A run whose only failure is its expectations exits 1 with its summary: each is a check.
145
+ const expectedOnly = exitCode === 1 && summary !== null && (summary.expectations?.failed ?? 0) > 0;
146
+ if ((exitCode !== 0 && !expectedOnly) || !summary) {
147
+ failures.push(`${what} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`);
121
148
  return null;
122
149
  }
150
+ for (const e of summary.expectations?.results ?? []) {
151
+ if (e.status === "unchecked") unchecked.push(`${what}: expect:${e.expect}`);
152
+ else {
153
+ checks++;
154
+ // A region or frame expectation read the frame: without a renderer it is unchecked.
155
+ if (e.kind === "region" || e.kind === "frame") frameChecks++;
156
+ if (e.status === "fail") failures.push(`${what}: expect:${e.expect} failed at tick ${e.tick}: expected ${JSON.stringify(e.expected)}, observed ${JSON.stringify(e.observed)}; ${e.reason}`);
157
+ }
158
+ }
123
159
  return summary;
124
- };
160
+ }
125
161
  const clock = desc!.clock ?? PLAYTEST_CLOCK;
126
- const summary = spawn(command(desc!.commands, out, clock), "og run");
162
+
163
+ // PlaytestDesc.room: a relay in this process, and every peer's process at
164
+ // once, each joined to the room (the first is the run, the others its peers).
165
+ // A `then` step relaunches every place at once into a room of its own
166
+ // (`<name>.then-<n>`), each running the step's script: lockstep keeps the
167
+ // steps on the room's shared ticks, so a `quit` ends every place on one tick.
168
+ const room = desc!.room;
169
+ const peerSummaries: Summary[] = [];
170
+ let summary: Summary | null;
171
+ let playRoom: ((name: string, scripts: (place: number) => string, dirOf: (place: number) => string, clock: number, what: string) => Promise<Summary[]>) | null = null;
172
+ if (room) {
173
+ const players = Math.floor(room.players);
174
+ if (!(players >= 2 && players <= 16)) failures.push(`room.players is ${room.players}: a room playtest takes 2 to 16 processes`);
175
+ if ((desc!.runs ?? 1) > 1) failures.push("a room playtest takes no `runs`: its processes play at once (a relaunch is a `then` step)");
176
+ const name = room.name ?? "playtest";
177
+ if (room.ticks !== undefined && !(Number.isInteger(room.ticks) && room.ticks > 0)) failures.push(`room.ticks is ${room.ticks}: the tick every process stops at, a whole number above 0 (or leave it out and end the script with quit)`);
178
+ if (!/^[A-Za-z0-9._-]{1,54}$/.test(name)) failures.push(`room.name '${name}' is not a room id: 1 to 54 of letters, digits, '.', '_' and '-' (a then step's room adds .then-<n>)`);
179
+ if (failures.length > 0) finish();
180
+ loadNativeHost();
181
+ const relay = JSON.parse(native().og_node_relay_start(0)) as { ok: boolean; value?: number; reason?: string };
182
+ if (!relay.ok) {
183
+ failures.push(`the playtest's relay did not start: ${relay.reason}`);
184
+ finish();
185
+ }
186
+ playRoom = async (roomName, scripts, dirOf, at, what) => {
187
+ const url = `ws://127.0.0.1:${relay.value}/${roomName}`;
188
+ const results = await Promise.all(
189
+ Array.from({ length: players }, async (_, i) => {
190
+ const dir = dirOf(i);
191
+ mkdirSync(dir, { recursive: true });
192
+ const p = Bun.spawn(command(scripts(i), dir, at, { url, ticks: room.ticks, place: i }), { stdout: "pipe", stderr: "pipe" });
193
+ const [code, stdout, stderr] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
194
+ return summarise(code, stdout, stderr, `${what} (peer ${i})`);
195
+ }),
196
+ );
197
+ if (results.some((r) => r === null)) finish();
198
+ return results as Summary[];
199
+ };
200
+ const results = await playRoom(name, (i) => room.commands?.[i] ?? desc!.commands, (i) => (i === 0 ? out : join(out, `peer-${i}`)), clock, "og run");
201
+ summary = results[0]!;
202
+ peerSummaries.push(...results.slice(1));
203
+ for (const [i, s] of peerSummaries.entries()) for (const f of s.unchecked ?? []) unchecked.push(`peer ${i + 1} ${f}`);
204
+ } else summary = spawn(command(desc!.commands, out, clock), "og run");
127
205
  if (!summary) finish();
128
206
  const rendered = summary!.tier !== "none";
129
207
  unchecked.push(...(summary!.unchecked ?? []));
130
208
  result = { ...result, tick: summary!.tick, hash: summary!.hash, tier: summary!.tier, warnings: summary!.warnings };
209
+ // A room's shared hashes, the run's first: equal ones are peers that stayed in step.
210
+ if (room) result = { ...result, netHashes: [summary!, ...peerSummaries].map((s) => s.netHash ?? ""), lockstep: [summary!, ...peerSummaries].map((s) => s.lockstep ?? null) };
131
211
  // More runs of the same script (desc.runs), each in a fresh process into its
132
212
  // own directory: only their end hashes are kept.
133
213
  const hashes = [summary!.hash];
@@ -137,7 +217,7 @@ for (let i = 1; i < Math.max(1, Math.floor(desc!.runs ?? 1)); i++) {
137
217
  }
138
218
  result = { ...result, hashes };
139
219
 
140
- const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): PlaytestRun => {
220
+ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[], peers: readonly PlaytestRun[] = []): PlaytestRun => {
141
221
  const path = (f: string) => {
142
222
  const p = resolve(dir, f);
143
223
  if (!existsSync(p)) throw new Error(`the run wrote no ${f} (it wrote ${s.outputs.map((o) => o.slice(dir.length + 1)).join(", ") || "nothing"})`);
@@ -156,6 +236,9 @@ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): Playtest
156
236
  files: s.outputs.map((o) => o.slice(dir.length + 1)),
157
237
  warnings: s.warnings,
158
238
  then: later,
239
+ netHash: s.netHash ?? "",
240
+ lockstep: s.lockstep ?? null,
241
+ peers,
159
242
  json: <T>(f: string) => JSON.parse(readFileSync(path(f), "utf8")) as T,
160
243
  text: (f: string) => readFileSync(path(f), "utf8"),
161
244
  png: (f: string) => {
@@ -183,17 +266,30 @@ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): Playtest
183
266
  },
184
267
  };
185
268
  };
186
- // The later processes (desc.then), in order, after the script and its runs.
269
+ // The later processes (desc.then), in order, after the script and its runs;
270
+ // in a room every place again, at once (their runs are each step's `peers`).
187
271
  const later: PlaytestRun[] = [];
188
272
  for (const [i, step] of (desc!.then ?? []).entries()) {
189
273
  const dir = join(out, `then-${i + 1}`);
190
274
  mkdirSync(dir, { recursive: true });
275
+ if (playRoom) {
276
+ const peerDir = (p: number) => join(dir, `peer-${p}`);
277
+ const all = await playRoom(`${room!.name ?? "playtest"}.then-${i + 1}`, () => step.commands, (p) => (p === 0 ? dir : peerDir(p)), step.clock ?? clock, `then[${i}]`);
278
+ for (const [p, s] of all.entries()) for (const f of s.unchecked ?? []) unchecked.push(`then[${i}]${p > 0 ? ` peer ${p}` : ""} ${f}`);
279
+ later.push(runOf(all[0]!, dir, [], all.slice(1).map((s, p) => runOf(s, peerDir(p + 1), []))));
280
+ continue;
281
+ }
191
282
  const s = spawn(command(step.commands, dir, step.clock ?? clock), `then[${i}]`);
192
283
  if (!s) finish();
193
284
  for (const f of s!.unchecked ?? []) unchecked.push(`then[${i}] ${f}`);
194
285
  later.push(runOf(s!, dir, []));
195
286
  }
196
- const run = runOf(summary!, out, later);
287
+ const run = runOf(
288
+ summary!,
289
+ out,
290
+ later,
291
+ peerSummaries.map((s, i) => runOf(s, join(out, `peer-${i + 1}`), [])),
292
+ );
197
293
  // Every file a run took a screenshot for without a renderer, by name and by path.
198
294
  const missedPaths = new Set<string>([...(summary!.unchecked ?? []).map((f) => resolve(out, f)), ...later.flatMap((r, i) => r.unchecked.map((f) => resolve(join(out, `then-${i + 1}`), f)))]);
199
295
  const missedNames = new Set<string>([...(summary!.unchecked ?? []), ...later.flatMap((r) => r.unchecked)]);
@@ -240,7 +336,7 @@ function missedFrame(e: unknown): string | null {
240
336
  return null;
241
337
  }
242
338
  try {
243
- await desc!.check(run, t);
339
+ if (desc!.check) await desc!.check(run, t);
244
340
  } catch (e) {
245
341
  const frame = missedFrame(e);
246
342
  if (frame !== null) notes.push(`the check read ${frame}, a frame taken without a renderer, and stopped there: the checks after it did not run (put frame checks in t.frame so every other check runs)`);
@@ -78,6 +78,8 @@ interface UiDump {
78
78
  tick: number;
79
79
  /** The surface the CSS-pixel bounds are on (dpr: device pixels per CSS pixel). */
80
80
  surface?: { width: number; height: number; dpr: number } | null;
81
+ /** The configured UI style's name: "" is the engine's placeholder, prototype. */
82
+ style?: string | null;
81
83
  elements: Array<{ id: string; parent: string; kind?: string; disabled?: boolean; clipped?: boolean; text: string; x: number; y: number; width: number; height: number }>;
82
84
  }
83
85
 
@@ -223,7 +225,8 @@ async function runOne(file: string, entry: string, root: string, out: string): P
223
225
  quit = true;
224
226
  break;
225
227
  }
226
- if ((answer.tick ?? 0) > report!.untilTick) break;
228
+ // Its last step has run (a restore may have moved og.Time, and the steps with it).
229
+ if ((answer.pending ?? ((answer.tick ?? 0) > report!.untilTick ? 0 : 1)) === 0) break;
227
230
  }
228
231
  drain();
229
232
  const wav = join(out, "audio.wav");
@@ -625,6 +628,14 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
625
628
  const tickIndex = new Map(s.map((p, i) => [p.tick, i]));
626
629
  const findings: Finding[] = [];
627
630
 
631
+ // The UI's style: a game that shows UI in the engine's placeholder has
632
+ // not chosen its own look (og-taste §1).
633
+ const dressed = rec.shots
634
+ .filter((shot) => shot.ui && existsSync(shot.ui))
635
+ .map((shot) => JSON.parse(readFileSync(shot.ui!, "utf8")) as UiDump)
636
+ .filter((ui) => ui.elements.length > 0 && typeof ui.style === "string");
637
+ if (dressed.some((ui) => ui.style === "")) findings.push({ measure: "UI style", where: rec.playtest, detail: "the UI draws in prototype, the engine's placeholder style (greys, square corners), not a style of the game's own", fix: "og-taste §1: name a style in the game's look (`ui \"<slug>\"` or `ui \"styles/<name>/style.ogt\"`), or extend a catalogue preset, which names one" });
638
+
628
639
  // Frames: contrast, text size, HUD.
629
640
  const shots: PlaytestMeasures["shots"] = [];
630
641
  const allTexts: Array<TextBox & { shot: string }> = [];
@@ -659,14 +670,14 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
659
670
  }
660
671
  const contrastPass = allTexts.filter((b) => b.contrast >= 4.5);
661
672
  const worstContrast = [...allTexts].sort((a, b) => a.contrast - b.contrast).slice(0, 3).map((b) => ({ shot: b.shot, id: b.id, text: b.text.slice(0, 40), ratio: b.contrast }));
662
- for (const w of worstContrast.filter((w) => w.ratio < 4.5)) findings.push({ measure: "contrast", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `${w.ratio}:1 between ink and ground (body text wants 4.5:1)`, fix: "og-taste §3 (value before hue): darken the ground or lighten the ink in the skin's palette, never per call" });
673
+ for (const w of worstContrast.filter((w) => w.ratio < 4.5)) findings.push({ measure: "contrast", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `${w.ratio}:1 between ink and ground (body text wants 4.5:1)`, fix: "og-taste §3 (value before hue): darken the ground or lighten the ink in the style's palette, never per call" });
663
674
  const sized = allTexts.filter((b) => b.ink > 0);
664
675
  // Ink lines of at least 9 px are a 13 px face (x-height and caps to descenders).
665
676
  const sizePass = sized.filter((b) => b.ink >= 9);
666
677
  const smallest = [...sized].sort((a, b) => a.ink - b.ink).slice(0, 3).map((b) => ({ shot: b.shot, id: b.id, text: b.text.slice(0, 40), ink: b.ink }));
667
678
  for (const w of smallest.filter((w) => w.ink < 9)) findings.push({ measure: "text size", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `ink lines ${w.ink} CSS px tall (a 13 px face's capitals and digits are about 9)`, fix: "og-taste §4 and §10.6: use the ramp's body step or larger for anything read on a phone" });
668
679
  if (gameplayShots > 0 && (worstShare > 15 || worstCentre > 5))
669
- findings.push({ measure: "HUD occupancy", where: `${rec.playtest}`, detail: `the HUD covers up to ${worstShare} % of the surface and ${worstCentre} % of the central third`, fix: "og-taste §2 (the play surface is the hero; HUD weight): capsules in the corners, the centre third free" });
680
+ findings.push({ measure: "HUD occupancy", where: `${rec.playtest}`, detail: `the HUD covers up to ${worstShare} % of the surface and ${worstCentre} % of the central third`, fix: "og-taste §2 (the play surface is the hero; HUD weight): HUD boxes in the corners, the centre third free" });
670
681
 
671
682
  // UI motion, transitions and sounds.
672
683
  let screenChanges = 0, withMotion = 0, motionTicks = 0;
@@ -727,7 +738,7 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
727
738
  else if (median(lat) > 1) findings.push({ measure: "input latency", where: rec.playtest, detail: `the median input is answered ${median(lat)} ticks after the tick it landed (0 is the same tick)`, fix: "og-feel §1: read input and act in the same tick; buffer, do not queue" });
728
739
  }
729
740
  if (screenChanges > 0 && withMotion < screenChanges) findings.push({ measure: "screen transitions", where: rec.playtest, detail: `${screenChanges - withMotion} of ${screenChanges} screen changes had no motion in the 20 ticks around them`, fix: "og-taste §5 (screens change through a transition): og.ui.screenTransition, one kind per game" });
730
- if (presses > 0 && pressesWithSound < presses) findings.push({ measure: "UI sounds", where: rec.playtest, detail: `${presses - pressesWithSound} of ${presses} UI presses played no sound on the ui bus`, fix: "og-taste §6 (feedback) and og-audio §3: give the skin or og.ui.configure its press and open sounds" });
741
+ if (presses > 0 && pressesWithSound < presses) findings.push({ measure: "UI sounds", where: rec.playtest, detail: `${presses - pressesWithSound} of ${presses} UI presses played no sound on the ui bus`, fix: "og-taste §6 (feedback) and og-audio §3: give the style or og.ui.configure its press and open sounds" });
731
742
 
732
743
  // Damage facts and their feedback.
733
744
  const moments: number[] = [];
@@ -0,0 +1,291 @@
1
+ // `og render` on the native host (docs/design.md §2.9): the game run
2
+ // headless at its own tick, one frame read back every `rate / fps` ticks,
3
+ // streamed to `og` (crates/og-cli/src/render.rs), which writes the frames,
4
+ // encodes the video and draws the contact sheet. One process of two kinds:
5
+ //
6
+ // bun src/node/render.ts --entry <game.js> [--root <project>] [--seed S] [--size WxH]
7
+ // [--commands "t:action[:args];..."] --fps F [--from <seconds>] [--warmup W]
8
+ // [--clock <UTC ms>] [--locale <tag>] [--out <dir>]
9
+ // a range: --first I --count N [--restore <snapshot>] [--audio <out.wav>]
10
+ // the leader: --leader --end N [--snapshot <frame>:<file>]... [--audio <out.wav>]
11
+ //
12
+ // Frame i of the render shows the world at tick T0 + i·k, where T0 is
13
+ // round(from · rate) and k = rate / fps (the fps must divide the tick rate).
14
+ // - A range sends frames I to I + N - 1, after W warm-up frames on the same
15
+ // grid before it (drawn, not sent: the renderer's history, TAA and the
16
+ // adapted exposure, is then that of a run that drew them). Ticks between
17
+ // frames are simulated without drawing. With --restore it starts from the
18
+ // leader's render snapshot, taken at the first warm-up frame's tick,
19
+ // instead of simulating from tick 0. With --audio the capture mixer's
20
+ // WAV from tick 0 to the range's end is written there.
21
+ // - The leader (`og render --jobs N`) simulates to frame N's tick without
22
+ // drawing, writing a render snapshot at the first warm-up tick of each
23
+ // --snapshot frame, and the WAV.
24
+ //
25
+ // Stdout carries records only: a little-endian u32 length, that many bytes
26
+ // of JSON, then the JSON's `bytes` of payload. A frame is
27
+ // `{kind:"frame", index, tick, time, width, height, sha256, bytes}` with its
28
+ // RGBA8 rows; the last record is `{kind:"end", ...}`. Everything else
29
+ // (the engine's log, warnings) goes to stderr. Outputs of the script's own
30
+ // steps (screenshot, dump_*) are written under --out.
31
+
32
+ import { mkdirSync, readFileSync, writeFileSync, writeSync } from "node:fs";
33
+ import { createHash } from "node:crypto";
34
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
35
+ import { pathToFileURL } from "node:url";
36
+ import { loadNativeHost, native } from "./index.ts";
37
+ import { openAudio } from "./audio.ts";
38
+ import { nativePlatform } from "./platform.ts";
39
+ import { createEngine, fixedClock, type Engine, type GameToken } from "../runtime.ts";
40
+
41
+ declare const Bun: { sleepSync(ms: number): void };
42
+
43
+ const argv = process.argv.slice(2);
44
+ let entry = "";
45
+ let root: string | undefined;
46
+ let seed: number | undefined;
47
+ let commands: string | undefined;
48
+ let size = { width: 1280, height: 720 };
49
+ let fps = 0;
50
+ let fromSeconds = 0;
51
+ let first: number | undefined;
52
+ let count: number | undefined;
53
+ let warmup = 0;
54
+ let restore: string | undefined;
55
+ let audioOut: string | undefined;
56
+ let leader = false;
57
+ let end: number | undefined;
58
+ let clock: number | undefined;
59
+ let locale: string | undefined;
60
+ let out: string | undefined;
61
+ const snapshots: { frame: number; file: string }[] = [];
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i]!;
64
+ const next = () => {
65
+ const v = argv[++i];
66
+ if (v === undefined) fail(`${a} needs a value`);
67
+ return v!;
68
+ };
69
+ const whole = (v: string) => {
70
+ const n = Number(v);
71
+ if (!Number.isInteger(n) || n < 0) fail(`${a} takes a whole number, not '${v}'`);
72
+ return n;
73
+ };
74
+ if (a === "--entry") entry = next();
75
+ else if (a === "--root") root = resolve(next());
76
+ else if (a === "--seed") seed = whole(next());
77
+ else if (a === "--commands") commands = next();
78
+ else if (a === "--size") {
79
+ const m = /^(\d+)x(\d+)$/.exec(next());
80
+ if (!m) fail("--size takes WIDTHxHEIGHT, such as 1280x720");
81
+ size = { width: Number(m![1]), height: Number(m![2]) };
82
+ } else if (a === "--fps") fps = whole(next());
83
+ else if (a === "--from") {
84
+ fromSeconds = Number(next());
85
+ if (!(Number.isFinite(fromSeconds) && fromSeconds >= 0)) fail("--from takes seconds, 0 or more");
86
+ } else if (a === "--first") first = whole(next());
87
+ else if (a === "--count") count = whole(next());
88
+ else if (a === "--warmup") warmup = whole(next());
89
+ else if (a === "--restore") restore = resolve(next());
90
+ else if (a === "--audio") audioOut = resolve(next());
91
+ else if (a === "--leader") leader = true;
92
+ else if (a === "--end") end = whole(next());
93
+ else if (a === "--snapshot") {
94
+ const v = next();
95
+ const at = v.indexOf(":");
96
+ if (at <= 0) fail("--snapshot takes <frame>:<file>");
97
+ snapshots.push({ frame: whole(v.slice(0, at)), file: resolve(v.slice(at + 1)) });
98
+ } else if (a === "--clock") clock = Number(next());
99
+ else if (a === "--locale") locale = next();
100
+ else if (a === "--out") out = resolve(next());
101
+ else fail(`unknown argument '${a}'`);
102
+ }
103
+
104
+ function fail(reason: string): never {
105
+ process.stderr.write(`og render: ${reason}\n`);
106
+ process.exit(2);
107
+ }
108
+
109
+ if (!entry) fail("no game entry: pass --entry <file>");
110
+ if (fps <= 0) fail("--fps takes frames per second, such as 30");
111
+ if (leader ? end === undefined : first === undefined || count === undefined) fail("a range takes --first and --count, the leader --end");
112
+
113
+ /** One record on stdout: length, JSON, payload. */
114
+ function record(header: Record<string, unknown>, payload?: Uint8Array): void {
115
+ const json = new TextEncoder().encode(JSON.stringify({ ...header, bytes: payload?.length ?? 0 }));
116
+ const len = new Uint8Array(4);
117
+ new DataView(len.buffer).setUint32(0, json.length, true);
118
+ writeAll(len);
119
+ writeAll(json);
120
+ if (payload) writeAll(payload);
121
+ }
122
+
123
+ function writeAll(bytes: Uint8Array): void {
124
+ let at = 0;
125
+ while (at < bytes.length) {
126
+ try {
127
+ at += writeSync(1, bytes, at, bytes.length - at);
128
+ } catch (e) {
129
+ // A pipe that is full answers EAGAIN on a non-blocking descriptor: wait.
130
+ if ((e as { code?: string }).code !== "EAGAIN") throw e;
131
+ Bun.sleepSync(1);
132
+ }
133
+ }
134
+ }
135
+
136
+ loadNativeHost();
137
+ const extras = native();
138
+ const mod = (await import(pathToFileURL(resolve(entry)).href)) as { default?: GameToken };
139
+ if (!mod.default) fail(`${entry} has no default export: export default og.game({ ... })`);
140
+ const projectText = root ? (() => {
141
+ try {
142
+ return readFileSync(join(root, "project.ogt"), "utf8");
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ })() : undefined;
147
+ const profile = projectText ? (/^\s*profile\s+"(2d|3d)"/m.exec(projectText)?.[1] as "2d" | "3d" | undefined) : undefined;
148
+ const created = createEngine(mod.default!, {
149
+ ...(seed === undefined ? {} : { seed }),
150
+ ...(root ? { root } : {}),
151
+ ...(projectText === undefined ? {} : { project: projectText }),
152
+ ...(profile ? { profile } : {}),
153
+ ...(locale ? { locale } : {}),
154
+ deferStart: true,
155
+ });
156
+ if (!created.ok) fail(`${entry}: ${created.reason}`);
157
+ const engine: Engine = created.value;
158
+ if (clock !== undefined) engine.setClock(fixedClock(engine, clock));
159
+ engine.attachPlatform(nativePlatform(undefined));
160
+
161
+ const rate = engine.rate;
162
+ if (rate % fps !== 0) {
163
+ const divisors = [];
164
+ for (let d = 1; d <= rate; d++) if (rate % d === 0) divisors.push(d);
165
+ const below = divisors.filter((d) => d < fps).pop();
166
+ const above = divisors.find((d) => d > fps);
167
+ fail(`--fps ${fps} does not divide the game's tick rate ${rate}: a frame would fall between ticks; the nearest that do are ${[below, above].filter((d) => d !== undefined).join(" and ")}`);
168
+ }
169
+ const step = rate / fps;
170
+ const tickMs = 1000 / rate;
171
+ const t0 = Math.round(fromSeconds * rate);
172
+ /** The tick frame `i` shows. */
173
+ const tickOf = (i: number): number => t0 + i * step;
174
+ /** The tick a range starting at frame `i` starts drawing at (its first warm-up frame). */
175
+ const warmTick = (i: number): number => Math.max(0, tickOf(i) - warmup * step);
176
+
177
+ // The renderer: the leader attaches one too, so GameDesc.start sees the same
178
+ // play surface (og.render.capabilities) in every process of the render.
179
+ const attached = JSON.parse(extras.og_node_attach_offscreen(engine.id, size.width, size.height)) as { ok: boolean; value?: string; reason?: string };
180
+ if (!attached.ok && !leader) fail(`no renderer (${attached.reason}): og render needs a headless renderer (og doctor)`);
181
+ const tier = attached.ok ? attached.value! : "none";
182
+ const started = engine.advance(0);
183
+ if (!started.ok) fail(started.reason);
184
+
185
+ const sink = (() => {
186
+ const opened = openAudio(engine.id, audioOut ? "capture" : "null");
187
+ if (!opened.ok) fail(`audio: ${opened.reason}`);
188
+ return opened.value;
189
+ })();
190
+
191
+ const warnings: string[] = [];
192
+ function drain(): void {
193
+ for (const o of engine.takeOutputs()) {
194
+ if (!out) continue;
195
+ const path = resolve(out, o.file);
196
+ const inside = relative(out, path);
197
+ if (inside === "" || inside.startsWith(".." + sep) || inside === ".." || isAbsolute(inside)) continue;
198
+ mkdirSync(dirname(path), { recursive: true });
199
+ writeFileSync(path, o.kind === "png" || o.kind === "wav" ? Uint8Array.from(atob(o.data), (c) => c.charCodeAt(0)) : o.data);
200
+ process.stderr.write(`tick ${o.tick} ${o.action}: wrote ${path}\n`);
201
+ }
202
+ for (const l of engine.takeLog()) {
203
+ const line = `${l.warning ? "WARN" : "INFO"} tick ${l.tick}: ${l.text}`;
204
+ if (l.warning) warnings.push(line);
205
+ process.stderr.write(line + "\n");
206
+ }
207
+ }
208
+ drain();
209
+
210
+ if (commands !== undefined) {
211
+ const report = engine.probe.commands(commands) as { warnings: string[] } | null;
212
+ drain();
213
+ if (!report) fail("--commands: the script is malformed (the reason is above)");
214
+ for (const w of report!.warnings) {
215
+ warnings.push(`WARN ${w}`);
216
+ process.stderr.write(`WARN ${w}\n`);
217
+ }
218
+ }
219
+ if (restore) {
220
+ const r = JSON.parse(extras.og_node_render_restore(engine.id, readFileSync(restore))) as { ok: boolean; value?: number; reason?: string };
221
+ if (!r.ok) fail(`--restore ${restore}: ${r.reason}`);
222
+ }
223
+
224
+ let quit = false;
225
+ const tick = (): number => engine.probe.tick();
226
+
227
+ /** Simulate to tick `t` without drawing. */
228
+ function simulate(t: number): void {
229
+ const n = t - tick();
230
+ if (n <= 0) return;
231
+ const r = engine.advance(n);
232
+ if (!r.ok) fail(r.reason);
233
+ sink.pump();
234
+ drain();
235
+ // The probe takes no tick after a `quit` step.
236
+ if (tick() < t) quit = true;
237
+ }
238
+
239
+ /** Bring the world to tick `t` and draw it there. */
240
+ function drawAt(t: number): void {
241
+ if (t > tick()) simulate(t - 1);
242
+ if (quit) return;
243
+ const answer = engine.frame(t > tick() ? tickMs : 0);
244
+ if (!answer.ok) fail(answer.reason ?? "the frame failed");
245
+ sink.pump();
246
+ drain();
247
+ if (answer.quit) quit = true;
248
+ if (!answer.rendered) fail("the frame was not drawn");
249
+ }
250
+
251
+ if (leader) {
252
+ for (const s of [...snapshots].sort((a, b) => a.frame - b.frame)) {
253
+ const at = warmTick(s.frame);
254
+ simulate(at);
255
+ if (tick() !== at) fail(`the world stopped at tick ${tick()} before snapshot tick ${at} (a quit step?)`);
256
+ const blob = extras.og_node_render_snapshot(engine.id);
257
+ if (blob.length === 0) fail(`the engine refused a snapshot at tick ${at}`);
258
+ writeFileSync(s.file, blob);
259
+ }
260
+ simulate(tickOf(end!));
261
+ } else {
262
+ const from = tickOf(first!);
263
+ // Warm-up frames on the frame grid before the range: drawn, not sent.
264
+ for (let t = Math.max(tick(), warmTick(first!)); t < from && !quit; t += step) drawAt(t);
265
+ let sent = 0;
266
+ for (let i = first!; i < first! + count! && !quit; i++) {
267
+ const t = tickOf(i);
268
+ drawAt(t);
269
+ if (quit) break;
270
+ const rgba = extras.og_node_render_frame(engine.id);
271
+ if (rgba.length !== size.width * size.height * 4) fail(`frame ${i} read back ${rgba.length} bytes, not ${size.width}x${size.height} RGBA`);
272
+ const sha256 = createHash("sha256").update(rgba).digest("hex");
273
+ record({ kind: "frame", index: i, tick: t, time: t / rate, width: size.width, height: size.height, sha256 }, rgba);
274
+ sent++;
275
+ }
276
+ // The audio runs to the range's end, the last frame's span included.
277
+ if (audioOut && !quit) simulate(tickOf(first! + count!));
278
+ if (sent < count!) warnings.push(`WARN the script quit at tick ${tick()}: ${sent} of ${count} frames`);
279
+ }
280
+
281
+ let wavSha256: string | undefined;
282
+ if (audioOut) {
283
+ const wav = sink.wav();
284
+ mkdirSync(dirname(audioOut), { recursive: true });
285
+ writeFileSync(audioOut, wav);
286
+ wavSha256 = createHash("sha256").update(wav).digest("hex");
287
+ }
288
+ sink.close();
289
+ const dump = engine.probe.dump({ what: "world" }) as unknown as { hash: string };
290
+ record({ kind: "end", tick: tick(), hash: dump.hash, rate, fromTick: t0, step, tier, quit, warnings, wavSha256 });
291
+ process.exit(0);