@origingame/engine 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/og.mjs +1 -1
- package/dist/web/origin-engine.d.ts +219 -5
- package/dist/web/origin-engine.js +1344 -125
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +68 -3
- package/dist/web-publish/origin-engine-lanes.js +1007 -22
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +162 -5
- package/dist/web-publish/origin-engine.js +1004 -19
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +52 -3
- package/dist/web-publish-2d/origin-engine-lanes.js +1007 -22
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +162 -5
- package/dist/web-publish-2d/origin-engine.js +1004 -19
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +52 -3
- package/knowledge/looks/catalogue.json +40 -40
- package/knowledge/package.json +1 -1
- 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 +19 -10
- package/knowledge/skills/og-build/SKILL.md +8 -5
- package/knowledge/skills/og-core/SKILL.md +11 -0
- package/knowledge/skills/og-game/SKILL.md +877 -8
- package/knowledge/skills/og-platform-sdk/SKILL.md +1 -0
- package/knowledge/skills/og-taste/SKILL.md +82 -73
- package/knowledge/skills/og-ui/SKILL.md +65 -50
- package/knowledge/wiki/ai.md +20 -20
- package/knowledge/wiki/anim.md +384 -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 +262 -7
- package/knowledge/wiki/input.md +47 -47
- package/knowledge/wiki/load.md +236 -68
- 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 +15 -15
- package/knowledge/wiki/render.md +3893 -1896
- package/knowledge/wiki/rng.md +14 -14
- 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 +19 -19
- 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 +559 -629
- package/knowledge/wiki/video.md +157 -0
- package/knowledge/wiki/world.md +36 -36
- package/package.json +4 -3
- package/schema/api.schema.json +21707 -14725
- package/shell/page.ts +26 -2
- package/shell/platform.ts +46 -0
- package/shell/player.ts +7 -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 +189 -3
- package/src/index.ts +25 -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 +114 -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/playtest.ts +57 -4
- package/src/runtime.ts +13 -3
- 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 +2 -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 +58 -0
- package/src/web/worker.ts +103 -2
- package/types/bindings.json +9101 -6647
- package/types/index.d.ts +15503 -11564
package/src/node/run.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// bun src/node/run.ts --entry <game.js|.ts> [--root <project>] [--ticks N] [--seed S]
|
|
6
6
|
// [--setup action[:arg...]]... [--commands "t:action[:args];..."] [--out <dir>]
|
|
7
7
|
// [--port P] [--size WxH] [--realtime] [--watch <bundle.js>] [--no-render]
|
|
8
|
-
// [--audio null|device|<out.wav>] [--platform-store <file.json>] [--clock <UTC ms>] [--locale <tag>]
|
|
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 {
|
|
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
|
|
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
|
-
|
|
457
|
+
if (room !== undefined) extras.og_node_room_close(engine.id);
|
|
458
|
+
process.exit(frameFailed || expectations.failed > 0 ? 1 : 0);
|
package/src/p2.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The phase 2 modules' namespaces (IDL `terrain`): thin calls into the host's binding symbols,
|
|
1
|
+
// The phase 2 and depth modules' namespaces (IDL `terrain`, `stream`, `path`, `timeline`): thin calls into the host's binding symbols,
|
|
2
2
|
// typed by the generated declarations through index.ts.
|
|
3
3
|
|
|
4
4
|
import type { HostAbi } from "./generated/abi.ts";
|
|
@@ -33,3 +33,21 @@ export const stream = Object.freeze({
|
|
|
33
33
|
budget: (world: W) => parse(abi().og_stream_budget(world.id)),
|
|
34
34
|
setBudget: (world: W, bytes: number) => parse(abi().og_stream_set_budget(world.id, bytes)),
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// Depth S2 (IDL `path`, `timeline`).
|
|
38
|
+
export const path = Object.freeze({
|
|
39
|
+
create: (world: W, desc?: unknown) => parse(abi().og_path_create(world.id, json(desc))),
|
|
40
|
+
find: (world: W, name: string) => parse(abi().og_path_find(world.id, name)),
|
|
41
|
+
sample: (world: W, p: number, t: number) => parse(abi().og_path_sample(world.id, p, t)),
|
|
42
|
+
length: (world: W, p: number) => parse(abi().og_path_length(world.id, p)),
|
|
43
|
+
nearest: (world: W, p: number, point: unknown) =>
|
|
44
|
+
parse(abi().og_path_nearest(world.id, p, JSON.stringify(point))),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const timeline = Object.freeze({
|
|
48
|
+
info: (world: W, asset: number) => parse(abi().og_timeline_info(world.id, asset)),
|
|
49
|
+
play: (world: W, entity: number) => parse(abi().og_timeline_play(world.id, entity)),
|
|
50
|
+
pause: (world: W, entity: number) => parse(abi().og_timeline_pause(world.id, entity)),
|
|
51
|
+
seek: (world: W, entity: number, time: number) => parse(abi().og_timeline_seek(world.id, entity, time)),
|
|
52
|
+
stop: (world: W, entity: number) => parse(abi().og_timeline_stop(world.id, entity)),
|
|
53
|
+
});
|
package/src/playtest.ts
CHANGED
|
@@ -52,6 +52,42 @@ export interface PlaytestRun {
|
|
|
52
52
|
wav(file: string): { rate: number; frames: number; peak: number; rms: number };
|
|
53
53
|
/** The runs of PlaytestDesc.then, in order, each with its own files (empty without `then`). */
|
|
54
54
|
readonly then: readonly PlaytestRun[];
|
|
55
|
+
/** og.net's shared hash at the end (og.net.hash): the world without this process's session, what lockstep peers compare. */
|
|
56
|
+
readonly netHash: string;
|
|
57
|
+
/** With PlaytestDesc.room: og.net.status at the end (running, the start tick, the seats, the first divergent tick); null outside a room. */
|
|
58
|
+
readonly lockstep: PlaytestLockstep | null;
|
|
59
|
+
/** With PlaytestDesc.room: the room's other processes (peer scripts 1, 2, ...), each with its own files; empty outside a room. */
|
|
60
|
+
readonly peers: readonly PlaytestRun[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** og.net.status of a playtest process in a room, at its end. */
|
|
64
|
+
export interface PlaytestLockstep {
|
|
65
|
+
readonly running: boolean;
|
|
66
|
+
readonly peer: number | null;
|
|
67
|
+
readonly startTick: number | null;
|
|
68
|
+
readonly players: number | null;
|
|
69
|
+
readonly waits: number;
|
|
70
|
+
/** The first divergent tick, once og.Desync was sent. */
|
|
71
|
+
readonly desync: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** PlaytestDesc.room: the script's processes play together in one room. */
|
|
75
|
+
export interface PlaytestRoom {
|
|
76
|
+
/** How many processes join the room (2 to 16). */
|
|
77
|
+
readonly players: number;
|
|
78
|
+
/** Each process's script by its place (0 is the playtest's own `commands`); a process without one runs `commands`. */
|
|
79
|
+
readonly commands?: readonly string[];
|
|
80
|
+
/** The room's name in the relay (default `playtest`). */
|
|
81
|
+
readonly name?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The tick every process stops at (`og run --ticks`), when the scripts do not end first: their
|
|
84
|
+
* `netHash` there is what lockstep makes equal. Leave it out to end each script by its `quit`
|
|
85
|
+
* (or its last step). A script's steps are on the room's shared ticks once lockstep runs (a
|
|
86
|
+
* peer that takes the authority's snapshot at the start moves to its tick and its steps stay
|
|
87
|
+
* on theirs, a step whose tick it jumped past running at once), so a `quit` or an `expect` at
|
|
88
|
+
* tick t runs at tick t on every peer.
|
|
89
|
+
*/
|
|
90
|
+
readonly ticks?: number;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
/** The checks a playtest makes; a playtest that made none fails. */
|
|
@@ -106,9 +142,25 @@ export interface PlaytestDesc {
|
|
|
106
142
|
* process has its own store in memory.
|
|
107
143
|
*/
|
|
108
144
|
readonly store?: "keep";
|
|
109
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Play the script in a room (docs/design.md §2.8, §2.10): the playtest starts a relay
|
|
147
|
+
* (`og relay`, in its own process) and runs `players` processes at once, each `og run
|
|
148
|
+
* --room ws://127.0.0.1:P/<name>` with its own script (`room.commands[i]`, else `commands`)
|
|
149
|
+
* and its own files. The first is the run; the others are `run.peers`. Every process stops
|
|
150
|
+
* at `room.ticks`, or at its script's `quit`, which lands on one tick of the room for all.
|
|
151
|
+
* `runs` is refused with it; each `then` step relaunches every process at once into a room of
|
|
152
|
+
* its own (`<name>.then-<n>`), each running the step's script, its runs in `run.then[n].peers`;
|
|
153
|
+
* `store: "keep"` keeps one store per place across them.
|
|
154
|
+
*/
|
|
155
|
+
readonly room?: PlaytestRoom;
|
|
156
|
+
/** More processes after the script, in order, each a fresh `og run` (a relaunch): their runs are `run.then`. With `room`, every place of the room relaunches at once. */
|
|
110
157
|
readonly then?: readonly PlaytestStep[];
|
|
111
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Checks over what the run wrote. Optional when the script carries `expect:` steps: each
|
|
160
|
+
* expectation is a check (a failed one fails the playtest with its tick and observed value,
|
|
161
|
+
* a frame one without a renderer is unchecked), so a playtest can be its script alone.
|
|
162
|
+
*/
|
|
163
|
+
readonly check?: (run: PlaytestRun, t: PlaytestChecks) => void | Promise<void>;
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
/** A later process of a playtest (`PlaytestDesc.then`). */
|
|
@@ -124,8 +176,9 @@ export const PLAYTEST_CLOCK = 1767225600000;
|
|
|
124
176
|
|
|
125
177
|
/** Declare a playtest: the default export of a `tests/*.og.ts` file. */
|
|
126
178
|
export function playtest(desc: PlaytestDesc): PlaytestDesc {
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
const expects = /(^|;)\s*[\d.]+:expect:/.test(desc.commands ?? "");
|
|
180
|
+
if (!desc.name || !desc.commands || (typeof desc.check !== "function" && !expects)) {
|
|
181
|
+
throw new Error("playtest({ name, commands, check }) needs all three (check may be left out when the commands carry expect: steps)");
|
|
129
182
|
}
|
|
130
183
|
return Object.freeze({ ...desc });
|
|
131
184
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1311,6 +1311,10 @@ function makeProbe(id: number) {
|
|
|
1311
1311
|
commands: (script: string) => parse(a().og_engine_probe_commands(id, script)),
|
|
1312
1312
|
listen: (seconds?: number) => parse(a().og_engine_probe_listen(id, seconds)),
|
|
1313
1313
|
memory: () => parse(a().og_engine_probe_memory(id)),
|
|
1314
|
+
describe: () => parse(a().og_engine_probe_describe(id)),
|
|
1315
|
+
expect: (what: string) => parse(a().og_engine_probe_expect(id, what)),
|
|
1316
|
+
expectations: () => parse(a().og_engine_probe_expectations(id)),
|
|
1317
|
+
saveScene: (write?: boolean, spawned?: boolean) => parse(a().og_engine_probe_save_scene(id, write, spawned)),
|
|
1314
1318
|
});
|
|
1315
1319
|
}
|
|
1316
1320
|
|
|
@@ -1363,17 +1367,21 @@ export const load = Object.freeze({
|
|
|
1363
1367
|
environment: (world: WorldImpl, path: string) => parse(abi().og_load_environment(world.id, path)),
|
|
1364
1368
|
emitter: (world: WorldImpl, path: string) => parse(abi().og_load_emitter(world.id, path)),
|
|
1365
1369
|
shader: (world: WorldImpl, path: string) => parse(abi().og_load_shader(world.id, path)),
|
|
1366
|
-
|
|
1370
|
+
style: (world: WorldImpl, path: string) => parse(abi().og_load_style(world.id, path)),
|
|
1367
1371
|
look: (world: WorldImpl, path: string) => parse(abi().og_load_look(world.id, path)),
|
|
1368
1372
|
lookInfo: (world: WorldImpl, look: number) => parse(abi().og_load_look_info(world.id, look)),
|
|
1369
1373
|
palette: (world: WorldImpl, path: string) => parse(abi().og_load_palette(world.id, path)),
|
|
1370
1374
|
soundInfo: (world: WorldImpl, sound: number) => parse(abi().og_load_sound_info(world.id, sound)),
|
|
1375
|
+
video: (world: WorldImpl, path: string) => parse(abi().og_load_video(world.id, path)),
|
|
1376
|
+
videoInfo: (world: WorldImpl, video: number) => parse(abi().og_load_video_info(world.id, video)),
|
|
1371
1377
|
status: (world: WorldImpl, asset: number) => abi().og_load_status(world.id, asset),
|
|
1372
1378
|
error: (world: WorldImpl, asset: number) => parse(abi().og_load_error(world.id, asset)),
|
|
1373
1379
|
gltfInfo: (world: WorldImpl, asset: number) => parse(abi().og_load_gltf_info(world.id, asset)),
|
|
1374
1380
|
animation: (world: WorldImpl, path: string) => parse(abi().og_load_animation(world.id, path)),
|
|
1381
|
+
timeline: (world: WorldImpl, path: string) => parse(abi().og_load_timeline(world.id, path)),
|
|
1375
1382
|
flipbook: (world: WorldImpl, path: string) => parse(abi().og_load_flipbook(world.id, path)),
|
|
1376
1383
|
rig: (world: WorldImpl, path: string) => parse(abi().og_load_rig(world.id, path)),
|
|
1384
|
+
rig2d: (world: WorldImpl, path: string) => parse(abi().og_load_rig2d(world.id, path)),
|
|
1377
1385
|
tilemap: (world: WorldImpl, path: string) => parse(abi().og_load_tilemap(world.id, path)),
|
|
1378
1386
|
});
|
|
1379
1387
|
|
|
@@ -1381,8 +1389,10 @@ export const anim = Object.freeze({
|
|
|
1381
1389
|
graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
|
|
1382
1390
|
humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
|
|
1383
1391
|
retarget: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_retarget(world.id, json(desc))),
|
|
1384
|
-
|
|
1385
|
-
|
|
1392
|
+
rig2dInfo: (world: WorldImpl, rig: number) => parse(abi().og_anim_rig2d_info(world.id, rig)),
|
|
1393
|
+
skeleton2dBoxes: (world: WorldImpl, entity: number) => parse(abi().og_anim_skeleton2d_boxes(world.id, entity)),
|
|
1394
|
+
// One lookup for a flipbook's or a 2D rig's clips (an index, -1 when
|
|
1395
|
+
// missing) and a loaded model's (og.load.animation: a ClipAsset, 0 when missing).
|
|
1386
1396
|
clip: (info: { clips?: ReadonlyArray<{ clip: number; name: string }>; skeleton?: number }, name: string): number =>
|
|
1387
1397
|
info?.clips?.find((c) => c.name === name)?.clip ?? (info?.skeleton !== undefined ? 0 : -1),
|
|
1388
1398
|
});
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Depth S8's additions (IDL `render` render targets and custom post
|
|
2
|
+
// passes, `load.post`): thin calls into the host's binding symbols, typed by
|
|
3
|
+
// the generated declarations through index.ts.
|
|
4
|
+
|
|
5
|
+
import type { HostAbi } from "./generated/abi.ts";
|
|
6
|
+
import { boundHost } from "./runtime.ts";
|
|
7
|
+
|
|
8
|
+
type W = { readonly id: number };
|
|
9
|
+
|
|
10
|
+
function abi(): HostAbi<Uint32Array, unknown> {
|
|
11
|
+
const h = boundHost();
|
|
12
|
+
if (!h) throw new Error("no engine host is loaded: start the game through @origingame/engine's web or node loader");
|
|
13
|
+
return h.abi;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const parse = <T>(text: string): T => JSON.parse(text) as T;
|
|
17
|
+
const json = (value: unknown): string | undefined => (value === undefined ? undefined : JSON.stringify(value));
|
|
18
|
+
|
|
19
|
+
export const targetsRender = Object.freeze({
|
|
20
|
+
renderTarget: (world: W, desc?: unknown) => parse(abi().og_render_render_target(world.id, json(desc))),
|
|
21
|
+
setPostParam: (world: W, post: number, name: string, value: ReadonlyArray<number>) =>
|
|
22
|
+
parse(abi().og_render_set_post_param(world.id, post, name, JSON.stringify(value))),
|
|
23
|
+
postErrors: (world: W) => parse(abi().og_render_post_errors(world.id)),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const targetsLoad = Object.freeze({
|
|
27
|
+
post: (world: W, path: string) => parse(abi().og_load_post(world.id, path)),
|
|
28
|
+
});
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The run's trace in the Chrome trace format (the Trace Event Format's JSON
|
|
2
|
+
// object form, which Perfetto and chrome://tracing open), the same document
|
|
3
|
+
// on both hosts: native `og run --trace` (src/node/run.ts) and the web
|
|
4
|
+
// Worker (src/web/worker.ts, read through the shell's `trace()`).
|
|
5
|
+
//
|
|
6
|
+
// The engine records every span where it ran and hands them over as Chrome
|
|
7
|
+
// trace events on its own clock (crates/og-runtime/src/trace.rs: `{ now,
|
|
8
|
+
// events }`, `ts` in µs); a take is placed on the document's origin (Unix
|
|
9
|
+
// milliseconds) by the difference between the host's time and the engine's
|
|
10
|
+
// clock read together. Threads of process 1: 1 engine, 2 render, 3 load,
|
|
11
|
+
// 4 cook (og's, natively), 5 gpu, 16 + n og-lanes' pool thread n, 48 + n
|
|
12
|
+
// the cook's thread n; threads that appear mid-run arrive with their names.
|
|
13
|
+
|
|
14
|
+
export type TraceEvent = Record<string, unknown> & { ph: string; pid: number; tid: number; name: string; ts?: number; dur?: number };
|
|
15
|
+
|
|
16
|
+
/** A Chrome trace document as written to a file. */
|
|
17
|
+
export type TraceDocument = {
|
|
18
|
+
traceEvents: TraceEvent[];
|
|
19
|
+
displayTimeUnit: "ms";
|
|
20
|
+
otherData: { originUnixMs: number; producer: string; [k: string]: unknown };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The fixed threads and their names. */
|
|
24
|
+
export const TRACE_THREADS: readonly (readonly [number, string])[] = [
|
|
25
|
+
[1, "engine"],
|
|
26
|
+
[2, "render"],
|
|
27
|
+
[3, "load"],
|
|
28
|
+
[4, "cook"],
|
|
29
|
+
[5, "gpu"],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** The process and fixed threads' metadata events. */
|
|
33
|
+
export function traceMetadata(process: string): TraceEvent[] {
|
|
34
|
+
const out: TraceEvent[] = [{ name: "process_name", ph: "M", pid: 1, tid: 0, args: { name: process } }];
|
|
35
|
+
for (const [tid, name] of TRACE_THREADS) {
|
|
36
|
+
out.push({ name: "thread_name", ph: "M", pid: 1, tid, args: { name } });
|
|
37
|
+
out.push({ name: "thread_sort_index", ph: "M", pid: 1, tid, args: { sort_index: tid } });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Unix milliseconds, to the microsecond where the clock has it. */
|
|
43
|
+
export function unixMs(): number {
|
|
44
|
+
return performance.timeOrigin + performance.now();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A trace being recorded: takes appended as they come, the document at the end. */
|
|
48
|
+
export class Trace {
|
|
49
|
+
readonly events: TraceEvent[];
|
|
50
|
+
readonly origin: number;
|
|
51
|
+
readonly producer: string;
|
|
52
|
+
/** Thread names already in the document (a take names a thread once per engine trace; a document may hold several). */
|
|
53
|
+
private named = new Set<number>();
|
|
54
|
+
/** µs from the engine's clock to the document's origin, set by the first take. */
|
|
55
|
+
private shift: number | undefined;
|
|
56
|
+
|
|
57
|
+
/** A new document from `origin` (Unix ms), or one continued from `prior` (og's prelude: the cook). */
|
|
58
|
+
constructor(opts: { origin?: number; prior?: Partial<TraceDocument>; producer: string; process?: string }) {
|
|
59
|
+
this.producer = opts.producer;
|
|
60
|
+
if (opts.prior?.traceEvents) {
|
|
61
|
+
this.events = [...opts.prior.traceEvents];
|
|
62
|
+
this.origin = opts.prior.otherData?.originUnixMs ?? opts.origin ?? unixMs();
|
|
63
|
+
} else {
|
|
64
|
+
this.events = traceMetadata(opts.process ?? opts.producer);
|
|
65
|
+
this.origin = opts.origin ?? unixMs();
|
|
66
|
+
}
|
|
67
|
+
for (const e of this.events) if (e.ph === "M" && e.name === "thread_name") this.named.add(e.tid);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Places one take of the engine (`og_*_trace_take`'s JSON) on the
|
|
72
|
+
* document's origin: `hostNow` is the host's Unix ms read beside the take.
|
|
73
|
+
*/
|
|
74
|
+
append(taken: string | { now: number; events: TraceEvent[] }, hostNow: number = unixMs()): void {
|
|
75
|
+
const t = typeof taken === "string" ? (JSON.parse(taken) as { now: number; events: TraceEvent[] }) : taken;
|
|
76
|
+
// One shift for the whole trace, from the first take: both clocks are
|
|
77
|
+
// monotonic at the same rate, and a shift read again at every take
|
|
78
|
+
// would move spans that abut across two takes by its jitter (tens of
|
|
79
|
+
// µs), so that they overlap.
|
|
80
|
+
this.shift ??= Math.round((hostNow - t.now - this.origin) * 1000);
|
|
81
|
+
const shift = this.shift;
|
|
82
|
+
for (const e of t.events) {
|
|
83
|
+
if (e.ph === "M") {
|
|
84
|
+
if (e.name === "thread_name") {
|
|
85
|
+
if (this.named.has(e.tid)) continue;
|
|
86
|
+
this.named.add(e.tid);
|
|
87
|
+
}
|
|
88
|
+
this.events.push(e);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
this.events.push({ ...e, ts: (e.ts ?? 0) + shift });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
document(extra: Record<string, unknown> = {}): TraceDocument {
|
|
96
|
+
return { traceEvents: this.events, displayTimeUnit: "ms", otherData: { originUnixMs: this.origin, producer: this.producer, ...extra } };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What {@link checkTrace} found. */
|
|
101
|
+
export type TraceCheck = { errors: string[]; spans: number; threads: Record<number, string> };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Holds a document to the format as Perfetto's importer reads it, and to
|
|
105
|
+
* what this engine's traces promise: `traceEvents` a list; every `X` event
|
|
106
|
+
* a name, integer `ts`, `dur` ≥ 0 and integer `pid`/`tid`; metadata with
|
|
107
|
+
* `args.name`; every span on a thread nested inside the one it starts in
|
|
108
|
+
* (never overlapping it); and spans that lie where they ran. A thread whose
|
|
109
|
+
* children are laid end to end from their parent's start in every parent
|
|
110
|
+
* (the first child at the parent's `ts`, each next one where the previous
|
|
111
|
+
* ended), over at least `endToEndAfter` parents with two or more children,
|
|
112
|
+
* is reported: recorded starts leave gaps somewhere. The GPU's passes
|
|
113
|
+
* (`cat` "gpu") are exempt: one timestamp ends a pass and begins the next,
|
|
114
|
+
* so they are back to back by the measurement's construction.
|
|
115
|
+
*/
|
|
116
|
+
export function checkTrace(doc: unknown, endToEndAfter = 8): TraceCheck {
|
|
117
|
+
const errors: string[] = [];
|
|
118
|
+
const threads: Record<number, string> = {};
|
|
119
|
+
const d = doc as Partial<TraceDocument> | null;
|
|
120
|
+
if (!d || !Array.isArray(d.traceEvents)) return { errors: ["traceEvents is not a list"], spans: 0, threads };
|
|
121
|
+
const byTid = new Map<number, { ts: number; dur: number; name: string }[]>();
|
|
122
|
+
const backToBack = new Set<number>();
|
|
123
|
+
for (const e of d.traceEvents) {
|
|
124
|
+
if (!Number.isInteger(e.pid) || !Number.isInteger(e.tid)) errors.push(`${e.name}: pid/tid not integers`);
|
|
125
|
+
if (e.ph === "M") {
|
|
126
|
+
const name = (e.args as { name?: unknown } | undefined)?.name;
|
|
127
|
+
if ((e.name === "thread_name" || e.name === "process_name") && typeof name !== "string") errors.push(`${e.name} metadata without args.name`);
|
|
128
|
+
if (e.name === "thread_name" && typeof name === "string") threads[e.tid] = name;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (e.ph !== "X") continue;
|
|
132
|
+
if (typeof e.name !== "string" || !Number.isInteger(e.ts) || !Number.isInteger(e.dur) || (e.dur as number) < 0) {
|
|
133
|
+
errors.push(`malformed span ${JSON.stringify(e).slice(0, 120)}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
let list = byTid.get(e.tid);
|
|
137
|
+
if (!list) byTid.set(e.tid, (list = []));
|
|
138
|
+
list.push({ ts: e.ts as number, dur: e.dur as number, name: e.name });
|
|
139
|
+
if (e.cat === "gpu") backToBack.add(e.tid);
|
|
140
|
+
}
|
|
141
|
+
let spans = 0;
|
|
142
|
+
for (const [tid, list] of byTid) {
|
|
143
|
+
if (!(tid in threads)) errors.push(`thread ${tid} has spans and no name`);
|
|
144
|
+
spans += list.length;
|
|
145
|
+
// Parents first: earlier start, then the longer one.
|
|
146
|
+
list.sort((a, b) => a.ts - b.ts || b.dur - a.dur);
|
|
147
|
+
const stack: { ts: number; end: number; name: string; children: { ts: number; end: number }[] }[] = [];
|
|
148
|
+
const closed: { ts: number; children: { ts: number; end: number }[] }[] = [];
|
|
149
|
+
const close = () => closed.push(stack.pop()!);
|
|
150
|
+
for (const s of list) {
|
|
151
|
+
const end = s.ts + s.dur;
|
|
152
|
+
while (stack.length && stack[stack.length - 1]!.end <= s.ts) close();
|
|
153
|
+
const top = stack[stack.length - 1];
|
|
154
|
+
if (top && end > top.end) {
|
|
155
|
+
errors.push(`thread ${tid}: ${s.name} [${s.ts}, ${end}] overlaps ${top.name} [${top.ts}, ${top.end}] without nesting`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
top?.children.push({ ts: s.ts, end });
|
|
159
|
+
stack.push({ ts: s.ts, end, name: s.name, children: [] });
|
|
160
|
+
}
|
|
161
|
+
while (stack.length) close();
|
|
162
|
+
const parents = closed.filter((p) => p.children.length >= 2);
|
|
163
|
+
const laid = parents.filter((p) => p.children[0]!.ts === p.ts && p.children.every((c, i) => i === 0 || c.ts === p.children[i - 1]!.end));
|
|
164
|
+
if (!backToBack.has(tid) && parents.length >= endToEndAfter && laid.length === parents.length) {
|
|
165
|
+
errors.push(`thread ${tid} (${threads[tid] ?? "?"}): the children of all ${parents.length} parents are laid end to end from the parent's start: starts were derived, not recorded`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { errors, spans, threads };
|
|
169
|
+
}
|