@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.
- package/bin/og.mjs +1 -1
- package/dist/web/origin-engine.d.ts +255 -5
- package/dist/web/origin-engine.js +1584 -125
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +80 -3
- package/dist/web-publish/origin-engine-lanes.js +1288 -82
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +199 -6
- package/dist/web-publish/origin-engine.js +1285 -79
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +65 -4
- package/dist/web-publish-2d/origin-engine-lanes.js +1285 -79
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +198 -5
- package/dist/web-publish-2d/origin-engine.js +1282 -76
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +64 -3
- package/knowledge/README.md +2 -1
- package/knowledge/guides/three-to-og.md +393 -0
- package/knowledge/knowledge.json +2 -1
- package/knowledge/looks/catalogue.json +40 -40
- package/knowledge/package.json +3 -2
- package/knowledge/scaffold/distribution.json +14 -0
- package/knowledge/scaffold/origin-engine.json +2 -2
- package/knowledge/scaffold/template/README.md +2 -1
- package/knowledge/scaffold/template/assets/audio/sting.ogt +10 -0
- package/knowledge/scaffold/template/assets/timelines/intro.ogt +101 -0
- package/knowledge/scaffold/template/assets/timelines/narration.srt +11 -0
- package/knowledge/scaffold/template/host.d.ts +46 -0
- package/knowledge/scaffold/template/package.json +1 -1
- package/knowledge/scaffold/template/project.ogt +1 -1
- package/knowledge/scaffold/template/src/main.ts +1 -1
- package/knowledge/scaffold/template/tests/frames.ts +134 -0
- package/knowledge/scaffold/template/tests/intro.og.ts +65 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +30 -36
- package/knowledge/scaffold/template/tsconfig.json +1 -1
- package/knowledge/scripts/mount.ts +2 -1
- package/knowledge/scripts/validate.ts +159 -31
- package/knowledge/skills/og-audio/SKILL.md +2 -2
- package/knowledge/skills/og-build/SKILL.md +29 -7
- package/knowledge/skills/og-core/SKILL.md +31 -5
- package/knowledge/skills/og-feel/SKILL.md +2 -2
- package/knowledge/skills/og-game/SKILL.md +1331 -16
- package/knowledge/skills/og-i18n/SKILL.md +7 -7
- package/knowledge/skills/og-model/SKILL.md +374 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +2 -1
- package/knowledge/skills/og-taste/SKILL.md +82 -73
- package/knowledge/skills/og-ui/SKILL.md +80 -58
- package/knowledge/wiki/ai.md +20 -20
- package/knowledge/wiki/anim.md +392 -41
- package/knowledge/wiki/atmosphere.md +36 -13
- package/knowledge/wiki/audio.md +18 -18
- package/knowledge/wiki/camera.md +19 -13
- package/knowledge/wiki/commands.md +19 -13
- package/knowledge/wiki/debug.md +414 -0
- package/knowledge/wiki/game.md +8 -8
- package/knowledge/wiki/i18n.md +7 -7
- package/knowledge/wiki/index.md +317 -8
- package/knowledge/wiki/input.md +47 -47
- package/knowledge/wiki/load.md +238 -70
- package/knowledge/wiki/math.md +2607 -0
- package/knowledge/wiki/nav.md +34 -34
- package/knowledge/wiki/net.md +340 -0
- package/knowledge/wiki/path.md +402 -0
- package/knowledge/wiki/physics.md +95 -95
- package/knowledge/wiki/platform.md +463 -29
- package/knowledge/wiki/probe.md +681 -67
- package/knowledge/wiki/reflect.md +29 -17
- package/knowledge/wiki/render.md +4993 -1998
- package/knowledge/wiki/rng.md +14 -14
- package/knowledge/wiki/shapes.md +727 -0
- package/knowledge/wiki/stream.md +15 -15
- package/knowledge/wiki/system.md +8 -8
- package/knowledge/wiki/systems.md +2 -2
- package/knowledge/wiki/terrain.md +24 -20
- package/knowledge/wiki/tilemap.md +13 -13
- package/knowledge/wiki/time.md +4 -4
- package/knowledge/wiki/timeline.md +396 -0
- package/knowledge/wiki/transform.md +8 -8
- package/knowledge/wiki/tween.md +17 -17
- package/knowledge/wiki/ui.md +560 -654
- package/knowledge/wiki/video.md +157 -0
- package/knowledge/wiki/world.md +36 -36
- package/package.json +4 -3
- package/schema/api.schema.json +28151 -19640
- package/shell/page.ts +26 -2
- package/shell/platform.ts +51 -0
- package/shell/player.ts +15 -2
- package/shell/transport.ts +6 -0
- package/src/depth.ts +71 -0
- package/src/e6.ts +32 -1
- package/src/env.d.ts +5 -1
- package/src/generated/abi.ts +226 -4
- package/src/geom.ts +260 -0
- package/src/index.ts +32 -4
- package/src/light.ts +18 -0
- package/src/math.ts +622 -0
- package/src/node/index.ts +17 -0
- package/src/node/playtest-run.ts +115 -18
- package/src/node/polish.ts +13 -3
- package/src/node/render.ts +291 -0
- package/src/node/run.ts +86 -5
- package/src/p2.ts +19 -1
- package/src/platform-local.ts +2 -0
- package/src/playtest.ts +65 -4
- package/src/runtime.ts +100 -9
- package/src/targets.ts +28 -0
- package/src/trace.ts +169 -0
- package/src/ui-kit/huds.ts +34 -34
- package/src/ui-kit/menus.ts +12 -12
- package/src/ui-kit/settings.ts +11 -2
- package/src/ui-kit/store.ts +18 -18
- package/src/ui-kit/story.ts +6 -4
- package/src/ui-kit/util.ts +8 -8
- package/src/web/shell.ts +59 -1
- package/src/web/worker.ts +105 -4
- package/types/bindings.json +9368 -6619
- package/types/index.d.ts +16377 -11520
package/src/node/playtest-run.ts
CHANGED
|
@@ -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 ||
|
|
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,119 @@ 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
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
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
|
+
for (const step of (desc!.setup ?? "").split(";").map((s) => s.trim()).filter((s) => s !== "")) c.push("--setup", step);
|
|
116
|
+
const store = storeOf(room?.place ?? 0);
|
|
105
117
|
if (store) c.push("--platform-store", store);
|
|
106
118
|
return c;
|
|
107
119
|
};
|
|
108
|
-
type
|
|
120
|
+
type ExpectResult = { tick: number; expect: string; kind: string; status: "pass" | "fail" | "unchecked"; expected: unknown; observed: unknown; reason: string };
|
|
121
|
+
type Summary = {
|
|
122
|
+
tick: number;
|
|
123
|
+
hash: string;
|
|
124
|
+
netHash?: string;
|
|
125
|
+
lockstep?: PlaytestLockstep;
|
|
126
|
+
entities: number;
|
|
127
|
+
quit: boolean;
|
|
128
|
+
tier: string;
|
|
129
|
+
outputs: string[];
|
|
130
|
+
warnings: string[];
|
|
131
|
+
unchecked?: string[];
|
|
132
|
+
expectations?: { failed: number; results: ExpectResult[] };
|
|
133
|
+
};
|
|
109
134
|
const spawn = (c: string[], what: string): Summary | null => {
|
|
110
135
|
const r = Bun.spawnSync(c, { stdout: "pipe", stderr: "pipe" });
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
return summarise(r.exitCode, new TextDecoder().decode(r.stdout), new TextDecoder().decode(r.stderr), what);
|
|
137
|
+
};
|
|
138
|
+
function summarise(exitCode: number, stdout: string, stderr: string, what: string): Summary | null {
|
|
113
139
|
let summary: Summary | null = null;
|
|
114
140
|
try {
|
|
115
141
|
summary = JSON.parse(stdout.trim().split("\n").pop() ?? "");
|
|
116
142
|
} catch {
|
|
117
143
|
summary = null;
|
|
118
144
|
}
|
|
119
|
-
|
|
120
|
-
|
|
145
|
+
// A run whose only failure is its expectations exits 1 with its summary: each is a check.
|
|
146
|
+
const expectedOnly = exitCode === 1 && summary !== null && (summary.expectations?.failed ?? 0) > 0;
|
|
147
|
+
if ((exitCode !== 0 && !expectedOnly) || !summary) {
|
|
148
|
+
failures.push(`${what} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`);
|
|
121
149
|
return null;
|
|
122
150
|
}
|
|
151
|
+
for (const e of summary.expectations?.results ?? []) {
|
|
152
|
+
if (e.status === "unchecked") unchecked.push(`${what}: expect:${e.expect}`);
|
|
153
|
+
else {
|
|
154
|
+
checks++;
|
|
155
|
+
// A region or frame expectation read the frame: without a renderer it is unchecked.
|
|
156
|
+
if (e.kind === "region" || e.kind === "frame") frameChecks++;
|
|
157
|
+
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}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
123
160
|
return summary;
|
|
124
|
-
}
|
|
161
|
+
}
|
|
125
162
|
const clock = desc!.clock ?? PLAYTEST_CLOCK;
|
|
126
|
-
|
|
163
|
+
|
|
164
|
+
// PlaytestDesc.room: a relay in this process, and every peer's process at
|
|
165
|
+
// once, each joined to the room (the first is the run, the others its peers).
|
|
166
|
+
// A `then` step relaunches every place at once into a room of its own
|
|
167
|
+
// (`<name>.then-<n>`), each running the step's script: lockstep keeps the
|
|
168
|
+
// steps on the room's shared ticks, so a `quit` ends every place on one tick.
|
|
169
|
+
const room = desc!.room;
|
|
170
|
+
const peerSummaries: Summary[] = [];
|
|
171
|
+
let summary: Summary | null;
|
|
172
|
+
let playRoom: ((name: string, scripts: (place: number) => string, dirOf: (place: number) => string, clock: number, what: string) => Promise<Summary[]>) | null = null;
|
|
173
|
+
if (room) {
|
|
174
|
+
const players = Math.floor(room.players);
|
|
175
|
+
if (!(players >= 2 && players <= 16)) failures.push(`room.players is ${room.players}: a room playtest takes 2 to 16 processes`);
|
|
176
|
+
if ((desc!.runs ?? 1) > 1) failures.push("a room playtest takes no `runs`: its processes play at once (a relaunch is a `then` step)");
|
|
177
|
+
const name = room.name ?? "playtest";
|
|
178
|
+
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)`);
|
|
179
|
+
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>)`);
|
|
180
|
+
if (failures.length > 0) finish();
|
|
181
|
+
loadNativeHost();
|
|
182
|
+
const relay = JSON.parse(native().og_node_relay_start(0)) as { ok: boolean; value?: number; reason?: string };
|
|
183
|
+
if (!relay.ok) {
|
|
184
|
+
failures.push(`the playtest's relay did not start: ${relay.reason}`);
|
|
185
|
+
finish();
|
|
186
|
+
}
|
|
187
|
+
playRoom = async (roomName, scripts, dirOf, at, what) => {
|
|
188
|
+
const url = `ws://127.0.0.1:${relay.value}/${roomName}`;
|
|
189
|
+
const results = await Promise.all(
|
|
190
|
+
Array.from({ length: players }, async (_, i) => {
|
|
191
|
+
const dir = dirOf(i);
|
|
192
|
+
mkdirSync(dir, { recursive: true });
|
|
193
|
+
const p = Bun.spawn(command(scripts(i), dir, at, { url, ticks: room.ticks, place: i }), { stdout: "pipe", stderr: "pipe" });
|
|
194
|
+
const [code, stdout, stderr] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
195
|
+
return summarise(code, stdout, stderr, `${what} (peer ${i})`);
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
if (results.some((r) => r === null)) finish();
|
|
199
|
+
return results as Summary[];
|
|
200
|
+
};
|
|
201
|
+
const results = await playRoom(name, (i) => room.commands?.[i] ?? desc!.commands, (i) => (i === 0 ? out : join(out, `peer-${i}`)), clock, "og run");
|
|
202
|
+
summary = results[0]!;
|
|
203
|
+
peerSummaries.push(...results.slice(1));
|
|
204
|
+
for (const [i, s] of peerSummaries.entries()) for (const f of s.unchecked ?? []) unchecked.push(`peer ${i + 1} ${f}`);
|
|
205
|
+
} else summary = spawn(command(desc!.commands, out, clock), "og run");
|
|
127
206
|
if (!summary) finish();
|
|
128
207
|
const rendered = summary!.tier !== "none";
|
|
129
208
|
unchecked.push(...(summary!.unchecked ?? []));
|
|
130
209
|
result = { ...result, tick: summary!.tick, hash: summary!.hash, tier: summary!.tier, warnings: summary!.warnings };
|
|
210
|
+
// A room's shared hashes, the run's first: equal ones are peers that stayed in step.
|
|
211
|
+
if (room) result = { ...result, netHashes: [summary!, ...peerSummaries].map((s) => s.netHash ?? ""), lockstep: [summary!, ...peerSummaries].map((s) => s.lockstep ?? null) };
|
|
131
212
|
// More runs of the same script (desc.runs), each in a fresh process into its
|
|
132
213
|
// own directory: only their end hashes are kept.
|
|
133
214
|
const hashes = [summary!.hash];
|
|
@@ -137,7 +218,7 @@ for (let i = 1; i < Math.max(1, Math.floor(desc!.runs ?? 1)); i++) {
|
|
|
137
218
|
}
|
|
138
219
|
result = { ...result, hashes };
|
|
139
220
|
|
|
140
|
-
const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): PlaytestRun => {
|
|
221
|
+
const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[], peers: readonly PlaytestRun[] = []): PlaytestRun => {
|
|
141
222
|
const path = (f: string) => {
|
|
142
223
|
const p = resolve(dir, f);
|
|
143
224
|
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 +237,9 @@ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): Playtest
|
|
|
156
237
|
files: s.outputs.map((o) => o.slice(dir.length + 1)),
|
|
157
238
|
warnings: s.warnings,
|
|
158
239
|
then: later,
|
|
240
|
+
netHash: s.netHash ?? "",
|
|
241
|
+
lockstep: s.lockstep ?? null,
|
|
242
|
+
peers,
|
|
159
243
|
json: <T>(f: string) => JSON.parse(readFileSync(path(f), "utf8")) as T,
|
|
160
244
|
text: (f: string) => readFileSync(path(f), "utf8"),
|
|
161
245
|
png: (f: string) => {
|
|
@@ -183,17 +267,30 @@ const runOf = (s: Summary, dir: string, later: readonly PlaytestRun[]): Playtest
|
|
|
183
267
|
},
|
|
184
268
|
};
|
|
185
269
|
};
|
|
186
|
-
// The later processes (desc.then), in order, after the script and its runs
|
|
270
|
+
// The later processes (desc.then), in order, after the script and its runs;
|
|
271
|
+
// in a room every place again, at once (their runs are each step's `peers`).
|
|
187
272
|
const later: PlaytestRun[] = [];
|
|
188
273
|
for (const [i, step] of (desc!.then ?? []).entries()) {
|
|
189
274
|
const dir = join(out, `then-${i + 1}`);
|
|
190
275
|
mkdirSync(dir, { recursive: true });
|
|
276
|
+
if (playRoom) {
|
|
277
|
+
const peerDir = (p: number) => join(dir, `peer-${p}`);
|
|
278
|
+
const all = await playRoom(`${room!.name ?? "playtest"}.then-${i + 1}`, () => step.commands, (p) => (p === 0 ? dir : peerDir(p)), step.clock ?? clock, `then[${i}]`);
|
|
279
|
+
for (const [p, s] of all.entries()) for (const f of s.unchecked ?? []) unchecked.push(`then[${i}]${p > 0 ? ` peer ${p}` : ""} ${f}`);
|
|
280
|
+
later.push(runOf(all[0]!, dir, [], all.slice(1).map((s, p) => runOf(s, peerDir(p + 1), []))));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
191
283
|
const s = spawn(command(step.commands, dir, step.clock ?? clock), `then[${i}]`);
|
|
192
284
|
if (!s) finish();
|
|
193
285
|
for (const f of s!.unchecked ?? []) unchecked.push(`then[${i}] ${f}`);
|
|
194
286
|
later.push(runOf(s!, dir, []));
|
|
195
287
|
}
|
|
196
|
-
const run = runOf(
|
|
288
|
+
const run = runOf(
|
|
289
|
+
summary!,
|
|
290
|
+
out,
|
|
291
|
+
later,
|
|
292
|
+
peerSummaries.map((s, i) => runOf(s, join(out, `peer-${i + 1}`), [])),
|
|
293
|
+
);
|
|
197
294
|
// Every file a run took a screenshot for without a renderer, by name and by path.
|
|
198
295
|
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
296
|
const missedNames = new Set<string>([...(summary!.unchecked ?? []), ...later.flatMap((r) => r.unchecked)]);
|
|
@@ -240,7 +337,7 @@ function missedFrame(e: unknown): string | null {
|
|
|
240
337
|
return null;
|
|
241
338
|
}
|
|
242
339
|
try {
|
|
243
|
-
await desc!.check(run, t);
|
|
340
|
+
if (desc!.check) await desc!.check(run, t);
|
|
244
341
|
} catch (e) {
|
|
245
342
|
const frame = missedFrame(e);
|
|
246
343
|
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)`);
|
package/src/node/polish.ts
CHANGED
|
@@ -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
|
|
|
@@ -626,6 +628,14 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
|
|
|
626
628
|
const tickIndex = new Map(s.map((p, i) => [p.tick, i]));
|
|
627
629
|
const findings: Finding[] = [];
|
|
628
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
|
+
|
|
629
639
|
// Frames: contrast, text size, HUD.
|
|
630
640
|
const shots: PlaytestMeasures["shots"] = [];
|
|
631
641
|
const allTexts: Array<TextBox & { shot: string }> = [];
|
|
@@ -660,14 +670,14 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
|
|
|
660
670
|
}
|
|
661
671
|
const contrastPass = allTexts.filter((b) => b.contrast >= 4.5);
|
|
662
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 }));
|
|
663
|
-
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
|
|
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" });
|
|
664
674
|
const sized = allTexts.filter((b) => b.ink > 0);
|
|
665
675
|
// Ink lines of at least 9 px are a 13 px face (x-height and caps to descenders).
|
|
666
676
|
const sizePass = sized.filter((b) => b.ink >= 9);
|
|
667
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 }));
|
|
668
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" });
|
|
669
679
|
if (gameplayShots > 0 && (worstShare > 15 || worstCentre > 5))
|
|
670
|
-
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):
|
|
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" });
|
|
671
681
|
|
|
672
682
|
// UI motion, transitions and sounds.
|
|
673
683
|
let screenChanges = 0, withMotion = 0, motionTicks = 0;
|
|
@@ -728,7 +738,7 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
|
|
|
728
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" });
|
|
729
739
|
}
|
|
730
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" });
|
|
731
|
-
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
|
|
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" });
|
|
732
742
|
|
|
733
743
|
// Damage facts and their feedback.
|
|
734
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);
|