@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.
- 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 +4 -4
- 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 +21 -11
- package/knowledge/skills/og-build/SKILL.md +25 -6
- package/knowledge/skills/og-core/SKILL.md +11 -0
- package/knowledge/skills/og-feel/SKILL.md +8 -1
- package/knowledge/skills/og-game/SKILL.md +994 -21
- package/knowledge/skills/og-i18n/SKILL.md +12 -3
- package/knowledge/skills/og-platform-sdk/SKILL.md +4 -1
- package/knowledge/skills/og-taste/SKILL.md +82 -73
- package/knowledge/skills/og-ui/SKILL.md +82 -54
- 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 +265 -7
- package/knowledge/wiki/input.md +47 -47
- package/knowledge/wiki/load.md +236 -68
- package/knowledge/wiki/math.md +2614 -7
- 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 +124 -106
- package/knowledge/wiki/platform.md +465 -31
- package/knowledge/wiki/probe.md +681 -67
- package/knowledge/wiki/reflect.md +15 -15
- package/knowledge/wiki/render.md +3904 -1897
- 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 +19 -15
- 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 +603 -629
- package/knowledge/wiki/video.md +157 -0
- package/knowledge/wiki/world.md +67 -37
- package/package.json +7 -6
- package/schema/api.schema.json +21071 -13964
- 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 +26 -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 +15 -4
- package/src/node/render.ts +291 -0
- package/src/node/run.ts +91 -6
- package/src/p2.ts +19 -1
- package/src/playtest.ts +57 -4
- package/src/runtime.ts +15 -3
- package/src/targets.ts +28 -0
- package/src/trace.ts +169 -0
- package/src/ui-kit/huds.ts +49 -45
- 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 +8945 -6456
- package/types/index.d.ts +15507 -11437
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) {
|
|
@@ -304,12 +360,28 @@ if (!probing) {
|
|
|
304
360
|
break;
|
|
305
361
|
}
|
|
306
362
|
if (ticks !== undefined && tick >= ticks) break;
|
|
307
|
-
|
|
363
|
+
// The script ends once its last step has run: a restore moving og.Time
|
|
364
|
+
// forward or back moves the steps with it (they keep their delays), so
|
|
365
|
+
// the tick its last step was scheduled for is no end (brasswick #13).
|
|
366
|
+
const left = answer.pending ?? (tick > untilTick ? 0 : 1);
|
|
367
|
+
if (ticks === undefined && commands !== undefined && listening === undefined && !watchFile && left === 0) break;
|
|
308
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)));
|
|
309
375
|
}
|
|
310
376
|
}
|
|
311
377
|
const ms = performance.now() - t0;
|
|
312
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
|
+
}
|
|
313
385
|
let wavSha256: string | undefined;
|
|
314
386
|
if (audioMode === "capture") {
|
|
315
387
|
const wavPath = resolve(audioOut);
|
|
@@ -323,6 +395,8 @@ const audioStats = sink.stats();
|
|
|
323
395
|
sink.close();
|
|
324
396
|
if (frameFailed) process.stderr.write(`og run: ${frameFailed}\n`);
|
|
325
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();
|
|
326
400
|
// og polish's order statistics (src/node/polish.ts), so the two agree.
|
|
327
401
|
const percentile = (xs: number[], q: number): number => {
|
|
328
402
|
if (xs.length === 0) return 0;
|
|
@@ -355,19 +429,30 @@ const summary = {
|
|
|
355
429
|
ticks: ticks ?? dump.tick,
|
|
356
430
|
tick: dump.tick,
|
|
357
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() } : {}),
|
|
358
436
|
entities: dump.entities,
|
|
359
437
|
advanceMs: +ms.toFixed(2),
|
|
360
438
|
frames,
|
|
361
439
|
systems,
|
|
362
440
|
runtime: typeof Bun !== "undefined" ? `bun ${Bun.version}` : `node ${process.version}`,
|
|
363
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,
|
|
364
444
|
quit,
|
|
365
445
|
unchecked,
|
|
366
446
|
audio: { mode: audioMode, commands: audioStats.commands, receivedHash: audioStats.receivedHash, wavSha256 },
|
|
367
447
|
outputs,
|
|
368
448
|
warnings,
|
|
449
|
+
expectations,
|
|
369
450
|
};
|
|
370
451
|
if (asJson) process.stdout.write(JSON.stringify(summary) + "\n");
|
|
371
|
-
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
|
+
}
|
|
372
456
|
if (listening !== undefined) extras.og_node_probe_close(engine.id);
|
|
373
|
-
|
|
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
|
@@ -1179,6 +1179,8 @@ export class Engine {
|
|
|
1179
1179
|
/** Wall milliseconds a tick took (this frame's mean, or the last frame's that ticked) and the last render took. */
|
|
1180
1180
|
stepMs?: number;
|
|
1181
1181
|
renderMs?: number;
|
|
1182
|
+
/** The command channel's steps still to run (scheduled, or a screenshot waiting for its frame): a script has ended at 0, however a restore moved og.Time. */
|
|
1183
|
+
pending?: number;
|
|
1182
1184
|
reason?: string;
|
|
1183
1185
|
/** The UI's focused text control (web/text-input.ts's TextTarget), or null. */
|
|
1184
1186
|
text?: { path: string; rect: [number, number, number, number]; multiline: boolean; selection: string; composing: boolean } | null;
|
|
@@ -1309,6 +1311,10 @@ function makeProbe(id: number) {
|
|
|
1309
1311
|
commands: (script: string) => parse(a().og_engine_probe_commands(id, script)),
|
|
1310
1312
|
listen: (seconds?: number) => parse(a().og_engine_probe_listen(id, seconds)),
|
|
1311
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)),
|
|
1312
1318
|
});
|
|
1313
1319
|
}
|
|
1314
1320
|
|
|
@@ -1361,17 +1367,21 @@ export const load = Object.freeze({
|
|
|
1361
1367
|
environment: (world: WorldImpl, path: string) => parse(abi().og_load_environment(world.id, path)),
|
|
1362
1368
|
emitter: (world: WorldImpl, path: string) => parse(abi().og_load_emitter(world.id, path)),
|
|
1363
1369
|
shader: (world: WorldImpl, path: string) => parse(abi().og_load_shader(world.id, path)),
|
|
1364
|
-
|
|
1370
|
+
style: (world: WorldImpl, path: string) => parse(abi().og_load_style(world.id, path)),
|
|
1365
1371
|
look: (world: WorldImpl, path: string) => parse(abi().og_load_look(world.id, path)),
|
|
1366
1372
|
lookInfo: (world: WorldImpl, look: number) => parse(abi().og_load_look_info(world.id, look)),
|
|
1367
1373
|
palette: (world: WorldImpl, path: string) => parse(abi().og_load_palette(world.id, path)),
|
|
1368
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)),
|
|
1369
1377
|
status: (world: WorldImpl, asset: number) => abi().og_load_status(world.id, asset),
|
|
1370
1378
|
error: (world: WorldImpl, asset: number) => parse(abi().og_load_error(world.id, asset)),
|
|
1371
1379
|
gltfInfo: (world: WorldImpl, asset: number) => parse(abi().og_load_gltf_info(world.id, asset)),
|
|
1372
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)),
|
|
1373
1382
|
flipbook: (world: WorldImpl, path: string) => parse(abi().og_load_flipbook(world.id, path)),
|
|
1374
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)),
|
|
1375
1385
|
tilemap: (world: WorldImpl, path: string) => parse(abi().og_load_tilemap(world.id, path)),
|
|
1376
1386
|
});
|
|
1377
1387
|
|
|
@@ -1379,8 +1389,10 @@ export const anim = Object.freeze({
|
|
|
1379
1389
|
graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
|
|
1380
1390
|
humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
|
|
1381
1391
|
retarget: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_retarget(world.id, json(desc))),
|
|
1382
|
-
|
|
1383
|
-
|
|
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).
|
|
1384
1396
|
clip: (info: { clips?: ReadonlyArray<{ clip: number; name: string }>; skeleton?: number }, name: string): number =>
|
|
1385
1397
|
info?.clips?.find((c) => c.name === name)?.clip ?? (info?.skeleton !== undefined ? 0 : -1),
|
|
1386
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
|
+
}
|