@joystack/cli 0.0.0-stage → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +749 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +243 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +275 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +316 -0
  56. package/package.json +27 -4
@@ -0,0 +1,1379 @@
1
+ // The recorded session, turned into a table.
2
+ //
3
+ // A recording stores what the player PRESSED — the world it started from, plus
4
+ // every input stamped with the tick it arrived on. No position, no speed, no
5
+ // score appears anywhere in it. So nothing can point a query at a recording:
6
+ // everything worth asking about has to be re-derived by running the game
7
+ // forward. That is what this does. It steps the session through the game's own
8
+ // runtime and writes one row per (tick, entity), with every declared field as a
9
+ // column — after which the questions are ordinary SQL, and asking a new one
10
+ // costs nothing because the sim does not have to run again.
11
+ //
12
+ // Four tables come out on the tick axis, and a fifth describes the columns:
13
+ //
14
+ // <stream>.state.parquet one row per tick per entity, the wide sparse one
15
+ // <stream>.inputs.parquet one row per wire send — already rows, no replay
16
+ // <stream>.perf.parquet what each frame and second cost on the device
17
+ // that played, when the session was measured
18
+ // <stream>.info.parquet the session, its players and browsers, and the
19
+ // tick ranges the other three cannot vouch for
20
+ // <stream>.fields.parquet one row per state column and per channel: the
21
+ // trait and source line behind it
22
+ //
23
+ // The state table is wide and mostly repeated (a real session measured 8.7% of
24
+ // rows and 1.3% of cells changing between consecutive ticks). Delta-encoding it
25
+ // was considered and rejected: Parquet's columnar compression already absorbs
26
+ // the redundancy — 225 MB of JSONL becomes ~3 MB — and deltas would push
27
+ // last-value-carried-forward semantics into every query anybody writes.
28
+ import { mkdirSync, renameSync, rmSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+ import { ParquetWriter, fileWriter } from 'hyparquet-writer';
32
+ import { readTrackLimits, readTrackSegments, readBuildStamp } from './replay-store.mjs';
33
+ import { gameBuild } from './build-id.mjs';
34
+ import { sourcePath } from './source-path.mjs';
35
+
36
+ // The columns every state row carries, before any declared field. `def` is a
37
+ // column rather than a prefix on every field name — which is what lets a query
38
+ // say `WHERE def IN ('prop','saucer')` and read one `body_x`.
39
+ export const STATE_KEYS = ['tick', 'id', 'def', 'owner'];
40
+ const STATE_KEY_TYPES = { tick: 'DOUBLE', id: 'STRING', def: 'STRING', owner: 'STRING' };
41
+
42
+ // The columns every input row carries.
43
+ // `tick` is when a send ARRIVED. A predicting client stamps `targetTick` and the
44
+ // room holds the send in its jitter buffer until exactly that tick, so for any
45
+ // predicted input the tick it arrives at is not the tick its effect shows up at
46
+ // in the state table. Without this column a join on `tick` mis-attributes every
47
+ // one of them, silently.
48
+ const INPUT_KEYS = ['tick', 'target_tick', 'conn', 'seq', 'entity', 'channel', 'payload'];
49
+ const INPUT_KEY_TYPES = {
50
+ tick: 'DOUBLE', target_tick: 'DOUBLE', conn: 'STRING', seq: 'DOUBLE',
51
+ entity: 'STRING', channel: 'STRING', payload: 'JSON',
52
+ };
53
+
54
+ /**
55
+ * The logical column type a JS value implies.
56
+ *
57
+ * DOUBLE for every number, never INT: the engine has one numeric type (a JS
58
+ * double), and a column typed from whole-looking values is a column that throws
59
+ * a thousand rows later when a fraction arrives. JSON for anything that is not
60
+ * flat, and for anything that says nothing — a cell has to be a scalar, so a
61
+ * list or an object rides as text rather than being dropped.
62
+ */
63
+ function typeOf(value) {
64
+ if (value === null || value === undefined) return 'JSON';
65
+ switch (typeof value) {
66
+ case 'number': return 'DOUBLE';
67
+ case 'boolean': return 'BOOLEAN';
68
+ case 'string': return 'STRING';
69
+ default: return 'JSON';
70
+ }
71
+ }
72
+
73
+ // Two declarations of one column name that disagree. One Parquet column cannot
74
+ // be both a double and a string, and dropping it would lose both kinds' data
75
+ // silently — so the column widens to the one type that holds anything.
76
+ const merge = (a, b) => (a === b ? a : 'JSON');
77
+
78
+ /**
79
+ * The state table's shape, read off the lowered bundle.
80
+ *
81
+ * Types come from what each field was DECLARED as, never from a sample of what
82
+ * rows happen to hold. That is the whole reason this reads the manifests rather
83
+ * than the world: a sample is a guess that is right until it is not, and the
84
+ * failure lands mid-write, after the replay has already run.
85
+ *
86
+ * @param {{ manifests: Array<{component: string, owns: object}> }} bundle
87
+ * @returns {{ columns: Array<{name: string, type: string}>, conflicts: string[] }}
88
+ */
89
+ export function tableSchema(bundle) {
90
+ const declared = new Map();
91
+ const conflicts = new Set();
92
+ for (const manifest of bundle?.manifests ?? []) {
93
+ for (const [field, spec] of Object.entries(manifest.owns ?? {})) {
94
+ const type = typeOf(spec?.default);
95
+ if (!declared.has(field)) {
96
+ declared.set(field, type);
97
+ continue;
98
+ }
99
+ const merged = merge(declared.get(field), type);
100
+ if (merged !== declared.get(field) || merged !== type) conflicts.add(field);
101
+ declared.set(field, merged);
102
+ }
103
+ }
104
+ return {
105
+ columns: [
106
+ ...STATE_KEYS.map((name) => ({ name, type: STATE_KEY_TYPES[name] })),
107
+ // Sorted so the same game always exports the same column order, whatever
108
+ // order its definitions happened to be lowered in.
109
+ ...[...declared.keys()].sort().map((name) => ({ name, type: declared.get(name) })),
110
+ ],
111
+ conflicts: [...conflicts].sort(),
112
+ };
113
+ }
114
+
115
+ const FIELD_COLUMNS = [
116
+ ['row', 'STRING'], ['kind', 'STRING'], ['name', 'STRING'], ['trait', 'STRING'], ['instance', 'STRING'],
117
+ ['field', 'STRING'], ['type', 'STRING'], ['default', 'JSON'], ['source', 'STRING'], ['senders', 'JSON'],
118
+ ];
119
+
120
+ /**
121
+ * What each state column and each channel is. One `field` row per (kind,
122
+ * column) and one `channel` row per (kind, channel), each naming the trait
123
+ * behind it, the name the kind gave that trait (a column's prefix), and the
124
+ * file and line it is declared on; a field row adds the field's own name, its
125
+ * declared type and its starting value.
126
+ *
127
+ * Names alone do not say this. `contact_asleep` is the `asleep` field of
128
+ * whatever trait the kind calls `contact`; a channel is named by the file its
129
+ * token is exported from, so `ground-contact_sleep` shares no prefix with the
130
+ * `contact_*` fields its handler changes. Only the lowered game knows which
131
+ * trait is behind each. A name the game lowered without an origin keeps its
132
+ * row, with the origin null.
133
+ *
134
+ * A channel row also says who sends on it (`senders`): each query declared to
135
+ * send on it, by kind, trait and line, and `{ from: 'client' }` when players'
136
+ * browsers may send on it too.
137
+ *
138
+ * A file inside the engine's package is named `@joystack/engine/shared/…`,
139
+ * the same in every install, rather than by where this one sits.
140
+ *
141
+ * @param {{ manifests: Array<{component: string, owns: object}>, fieldOrigins?: object, channelOrigins?: object,
142
+ * channelSenders?: object }} bundle
143
+ * @param {{ dir?: string, sharedDir?: string }} [where] - the game folder, and
144
+ * the engine's shared/ tree the game was lowered with
145
+ */
146
+ export function fieldsTable(bundle, { dir = null, sharedDir = null } = {}) {
147
+ const originOf = (table, kind, name) => {
148
+ const byName = table?.[kind];
149
+ return byName && typeof byName === 'object' && Object.hasOwn(byName, name) ? byName[name] : null;
150
+ };
151
+ const sourceOf = (o) => {
152
+ const given = text(o?.file);
153
+ const file = sourcePath(given, { dir, sharedDir }) ?? given;
154
+ const line = Number.isSafeInteger(o?.line) && o.line > 0 ? o.line : null;
155
+ return file && line ? `${file}:${line}` : file;
156
+ };
157
+ const rows = [];
158
+ for (const manifest of bundle?.manifests ?? []) {
159
+ const kind = manifest.component;
160
+ for (const [column, spec] of Object.entries(manifest.owns ?? {})) {
161
+ const o = originOf(bundle?.fieldOrigins, kind, column);
162
+ rows.push({
163
+ row: 'field',
164
+ kind: text(kind),
165
+ name: text(column),
166
+ trait: text(o?.trait),
167
+ instance: text(o?.instance),
168
+ field: text(o?.field),
169
+ type: typeOf(spec?.default),
170
+ default: jsonCell(spec?.default ?? null),
171
+ source: sourceOf(o),
172
+ senders: null,
173
+ });
174
+ }
175
+ }
176
+ // Channels are listed from the manifests, so an engine that records no
177
+ // origins still gives every channel its row.
178
+ for (const manifest of bundle?.manifests ?? []) {
179
+ const kind = manifest.component;
180
+ const names = manifest.channels && typeof manifest.channels === 'object' ? Object.keys(manifest.channels) : [];
181
+ for (const name of names) {
182
+ const o = originOf(bundle?.channelOrigins, kind, name);
183
+ const known = o && typeof o === 'object' ? o : null;
184
+ // An engine that records no senders says nothing about them: null, not
185
+ // an empty list, which would claim nothing sends on the channel.
186
+ const recorded = bundle?.channelSenders && typeof bundle.channelSenders === 'object';
187
+ const declared = originOf(bundle?.channelSenders, kind, name);
188
+ const senders = (Array.isArray(declared) ? declared : [])
189
+ .filter((q) => q && typeof q === 'object')
190
+ .map((q) => ({ from: 'query', kind: text(q.kind), trait: text(q.trait), instance: text(q.instance), source: sourceOf(q) }));
191
+ if (manifest.channels[name]?.from === 'client') senders.push({ from: 'client' });
192
+ rows.push({
193
+ row: 'channel',
194
+ kind: text(kind),
195
+ name: text(name),
196
+ trait: text(known?.trait),
197
+ instance: text(known?.instance),
198
+ field: null,
199
+ type: null,
200
+ default: null,
201
+ source: sourceOf(known),
202
+ senders: recorded ? jsonCell(senders) : null,
203
+ });
204
+ }
205
+ }
206
+ const key = (r) => `${r.row === 'field' ? 0 : 1}\u0000${r.kind}\u0000${r.name}`;
207
+ rows.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
208
+ return { columns: FIELD_COLUMNS.map(([name, type]) => ({ name, type })), rows };
209
+ }
210
+
211
+ /**
212
+ * One cell, as its column's type — or null.
213
+ *
214
+ * A value that does not fit is null rather than coerced. Turning a string into
215
+ * 0, or a NaN into "some number", would put a value in the table that the
216
+ * session never held, and the whole point of this table is that a question
217
+ * asked of it gets the sim's real answer.
218
+ */
219
+ export function coerce(value, type) {
220
+ if (value === null || value === undefined) return null;
221
+ switch (type) {
222
+ // Number.isFinite rejects NaN and ±Infinity: Parquet has no way to carry
223
+ // them as doubles that any reader agrees on.
224
+ case 'DOUBLE': return typeof value === 'number' && Number.isFinite(value) ? value : null;
225
+ case 'BOOLEAN': return typeof value === 'boolean' ? value : null;
226
+ case 'STRING': return typeof value === 'string' ? value : null;
227
+ // A value, not a string: the Parquet writer serializes a JSON column
228
+ // once, and serializing it here too stores a JSON string of the JSON,
229
+ // which every reader hands back as a string. See `jsonCell`.
230
+ case 'JSON': return jsonCell(value);
231
+ default: return null;
232
+ }
233
+ }
234
+
235
+ // Deeper than any value a game or a track has reason to hold, and far short of
236
+ // the depth at which the Parquet writer's recursive walk overflows the stack.
237
+ const JSON_MAX_DEPTH = 64;
238
+
239
+ /**
240
+ * A JSON column's cell: a detached copy of the value as it is NOW, or null
241
+ * when JSON cannot carry it or it nests past `JSON_MAX_DEPTH`.
242
+ *
243
+ * A copy, because rows wait in a batch for several ticks before they are
244
+ * written, and a record field may hold a list or object a trait changes in
245
+ * place — a reference would be written with its later value on earlier rows.
246
+ * The depth is read off the serialized text, without recursion, so a value
247
+ * built to be deep cannot overflow this check either.
248
+ */
249
+ // JSON escapes the C0 controls but writes U+0080–009F as they are, and U+009B
250
+ // is a terminal's CSI. A cell read in DuckDB's csv or list mode reaches the
251
+ // terminal byte for byte, so they are dropped from every string in a cell.
252
+ const C1 = /[\u0080-\u009f]/g;
253
+ const noC1 = (_, v) => (typeof v === 'string' ? v.replace(C1, '') : v);
254
+
255
+ function jsonCell(value) {
256
+ let text;
257
+ try { text = JSON.stringify(value, noC1); } catch { return null; }
258
+ if (text === undefined) return null;
259
+ let depth = 0;
260
+ let inString = false;
261
+ for (let i = 0; i < text.length; i++) {
262
+ const c = text.charCodeAt(i);
263
+ if (inString) {
264
+ if (c === 92) i++; // backslash: skip the escaped character
265
+ else if (c === 34) inString = false;
266
+ } else if (c === 34) inString = true;
267
+ else if (c === 91 || c === 123) { if (++depth > JSON_MAX_DEPTH) return null; }
268
+ else if (c === 93 || c === 125) depth--;
269
+ }
270
+ return JSON.parse(text);
271
+ }
272
+
273
+ /**
274
+ * One state row: the fixed keys, then every declared field this entity holds.
275
+ *
276
+ * A record key is `<kind>.<field>`; the kind is dropped because `def` already
277
+ * carries it. A key the schema does not know is skipped rather than added —
278
+ * Parquet needs its columns fixed before the first row is written, so a column
279
+ * discovered mid-session could not be honoured anyway.
280
+ */
281
+ export function stateRow(entity, tick, columnIndex, tally) {
282
+ // The key columns go through `coerce` like every other cell. They used to be
283
+ // assigned straight through, so an id that was not a string reached the
284
+ // writer as one and threw a raw type error — after the entire replay had
285
+ // already been paid for.
286
+ const row = {
287
+ tick: coerce(tick, 'DOUBLE'),
288
+ id: coerce(entity.id, 'STRING'),
289
+ def: coerce(entity.def, 'STRING'),
290
+ owner: coerce(entity.owner, 'STRING'),
291
+ };
292
+ for (const [key, value] of Object.entries(entity.record ?? {})) {
293
+ const field = key.slice(key.indexOf('.') + 1);
294
+ const type = columnIndex.get(field);
295
+ if (!type) continue;
296
+ row[field] = coerce(value, type);
297
+ // NaN and ±Infinity are real values a world can hold (M2 carries them out
298
+ // of band through a keyframe for exactly that reason) and Parquet has no
299
+ // double that means them. Null is the right answer and a SILENT null is
300
+ // not: null already means "this kind has no such field", so a dropped NaN
301
+ // would be indistinguishable from an absent column.
302
+ if (tally && row[field] === null && type === 'DOUBLE' && typeof value === 'number') tally.undoubleable += 1;
303
+ }
304
+ return row;
305
+ }
306
+
307
+ /**
308
+ * The input table: one row per accepted wire send.
309
+ *
310
+ * No replay is needed — the recording already holds these as rows. A payload
311
+ * has no declared shape, so unlike the state table its columns are typed by
312
+ * reading EVERY event first. That is not the sampling rule 1 forbids: the whole
313
+ * list is in hand before a row is written, so this is an exact read of the data
314
+ * rather than a guess from its first few values.
315
+ *
316
+ * Each scalar payload key becomes `p_<key>`, and the whole payload also rides
317
+ * as JSON — so a question about a field that did not flatten is still one
318
+ * `json_extract` away rather than a re-export.
319
+ */
320
+ // How many payload keys may become their own column.
321
+ //
322
+ // A recording is UNTRUSTED input. The segment endpoint takes what a room sends
323
+ // and cannot demand an Origin (a multiplayer room posts from a worker that has
324
+ // none), so anything that can reach the dev server can append events — and one
325
+ // column per distinct key, uncapped, turns a 151 KB post into a multi-gigabyte
326
+ // out-of-memory abort that names neither the export nor the recording.
327
+ //
328
+ // Capping loses nothing: the whole payload is on every row as JSON anyway, so a
329
+ // question about a rare key is one `json_extract` away instead of a column. The
330
+ // keys that survive are the ones that appear MOST OFTEN, because a key on every
331
+ // event is the one worth a column and a key on one event is the one that is
332
+ // not.
333
+ const MAX_PAYLOAD_COLUMNS = 256;
334
+
335
+ export function inputSchema(session) {
336
+ const sends = (session?.events ?? []).filter((e) => e?.k === 'w');
337
+ const payloadTypes = new Map();
338
+ const seen = new Map();
339
+ for (const send of sends) {
340
+ for (const [key, value] of Object.entries(send.payload ?? {})) {
341
+ const type = typeOf(value);
342
+ payloadTypes.set(key, payloadTypes.has(key) ? merge(payloadTypes.get(key), type) : type);
343
+ seen.set(key, (seen.get(key) ?? 0) + 1);
344
+ }
345
+ }
346
+ const ranked = [...seen.keys()].sort((a, b) => (seen.get(b) - seen.get(a)) || a.localeCompare(b));
347
+ // Sorted by name after ranking, so the same recording always exports the same
348
+ // column order.
349
+ const keys = ranked.slice(0, MAX_PAYLOAD_COLUMNS).sort();
350
+ const columns = [
351
+ ...INPUT_KEYS.map((name) => ({ name, type: INPUT_KEY_TYPES[name] })),
352
+ ...keys.map((k) => ({ name: `p_${k}`, type: payloadTypes.get(k) })),
353
+ ];
354
+ return { columns, keys, types: payloadTypes, sends, truncatedKeys: ranked.length - keys.length };
355
+ }
356
+
357
+ /** One input row: the fixed columns, then each flattened payload key. */
358
+ export function inputRow(send, keys, types) {
359
+ const row = {
360
+ tick: coerce(send.t, 'DOUBLE'),
361
+ // Null, not a copy of `tick`: an input with no target tick was never
362
+ // held, and repeating arrival here would invent a fact.
363
+ target_tick: coerce(send.targetTick, 'DOUBLE'),
364
+ conn: coerce(send.connId, 'STRING'),
365
+ seq: coerce(send.seq, 'DOUBLE'),
366
+ entity: coerce(send.entityId, 'STRING'),
367
+ channel: coerce(send.channel, 'STRING'),
368
+ payload: coerce(send.payload ?? null, 'JSON'),
369
+ };
370
+ // Every column on every row, so a field an event did not carry reads as
371
+ // null rather than being absent — a Parquet column has one length.
372
+ for (const k of keys) row[`p_${k}`] = coerce(send.payload?.[k], types.get(k));
373
+ return row;
374
+ }
375
+
376
+ export function inputTable(session) {
377
+ const { columns, keys, types, sends, truncatedKeys } = inputSchema(session);
378
+ return { columns, rows: sends.map((send) => inputRow(send, keys, types)), truncatedKeys };
379
+ }
380
+
381
+ // The logical types above, as the Parquet schema elements the writer wants.
382
+ // Every column is OPTIONAL because the table is deliberately SPARSE: a kind
383
+ // that does not declare a field leaves it null on that row, and null is a
384
+ // different answer from zero.
385
+ const PARQUET_TYPE = {
386
+ DOUBLE: { type: 'DOUBLE' },
387
+ BOOLEAN: { type: 'BOOLEAN' },
388
+ STRING: { type: 'BYTE_ARRAY', converted_type: 'UTF8' },
389
+ JSON: { type: 'BYTE_ARRAY', converted_type: 'JSON' },
390
+ };
391
+
392
+ // ── the performance table (project note ofta45) ─────────────────────────────
393
+ //
394
+ // The third table: what the session COST, from the track the browser recorded
395
+ // beside it. Unlike state, none of this is re-derived — it was measured on the
396
+ // device that played, and no replay on another machine can reproduce it.
397
+ //
398
+ // One row per frame, one per finished second and one per long frame the
399
+ // browser reported (below), told apart by `row`, in one table rather than
400
+ // three: all are stamped with the ticks they cover, and a reader's question
401
+ // ("what was alive while frames were slow") joins the tick axis once. On a
402
+ // `long_frame` row, `at_ms` and `dt` are Chrome's start and duration for the
403
+ // frame's main-thread work, and its ticks are those of the frame record it was
404
+ // joined to. A `kind` row is one entity kind's share of a `second` row: the
405
+ // same second's `at_ms` and ticks, the time that kind's entities spent
406
+ // (`ms`, split into `tick_ms` and `present_ms`), how many trait calls that
407
+ // took and how many of its entities spent any, and how many of that kind were
408
+ // alive in the replayed world at the second's last tick. It is the second
409
+ // row's `kinds` and `traits` JSON, as rows a query can group by. `conn` is
410
+ // which browser measured it — in multiplayer each player
411
+ // writes their own track, and averaging two devices together would describe
412
+ // neither.
413
+ const PERF_KEYS = [
414
+ 'row', 'conn', 'seq', 'at_ms', 'tick_from', 'tick_to',
415
+ 'dt', 'tick_ms', 'present_ms', 'render_ms', 'sim_ms', 'other_ms', 'wait_ms',
416
+ 'window_ms', 'entities', 'calls', 'triangles', 'heap_delta_mb', 'dom', 'geometries', 'textures',
417
+ 'spawned', 'despawned', 'programs', 'snapshot', 'channels', 'render', 'traits', 'kinds',
418
+ 'blocking_ms', 'render_start', 'layout_start', 'scripts',
419
+ 'kind', 'ms', 'trait_calls', 'entities_spent', 'alive', 'alive_max',
420
+ ];
421
+ const PERF_KEY_TYPES = {
422
+ row: 'STRING', conn: 'STRING', seq: 'DOUBLE', at_ms: 'DOUBLE', tick_from: 'DOUBLE', tick_to: 'DOUBLE',
423
+ dt: 'DOUBLE', tick_ms: 'DOUBLE', present_ms: 'DOUBLE', render_ms: 'DOUBLE', sim_ms: 'DOUBLE',
424
+ other_ms: 'DOUBLE', wait_ms: 'DOUBLE', window_ms: 'DOUBLE', entities: 'DOUBLE',
425
+ calls: 'DOUBLE', triangles: 'DOUBLE',
426
+ heap_delta_mb: 'DOUBLE', dom: 'DOUBLE', geometries: 'DOUBLE', textures: 'DOUBLE',
427
+ spawned: 'JSON', despawned: 'JSON', programs: 'JSON', snapshot: 'JSON', channels: 'JSON',
428
+ render: 'JSON', traits: 'JSON', kinds: 'JSON',
429
+ blocking_ms: 'DOUBLE', render_start: 'DOUBLE', layout_start: 'DOUBLE', scripts: 'JSON',
430
+ kind: 'STRING', ms: 'DOUBLE', trait_calls: 'DOUBLE', entities_spent: 'DOUBLE', alive: 'DOUBLE', alive_max: 'DOUBLE',
431
+ };
432
+
433
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
434
+ // Chrome writes 0 for the render and layout start of a long frame that
435
+ // rendered nothing. A 0 in a time column reads as the page's first
436
+ // millisecond, so it is exported as no value.
437
+ const time = (v) => (num(v) > 0 ? v : null);
438
+ // A JSON column's value, copied and depth-checked; see `jsonCell`.
439
+ const json = (v) => (v === undefined || v === null ? null : jsonCell(v));
440
+
441
+ function perfRow(kind, header, rec, joinedTicks = null) {
442
+ const ticks = joinedTicks ?? (Array.isArray(rec.ticks) && rec.ticks.length === 2 ? rec.ticks : [null, null]);
443
+ return {
444
+ row: kind,
445
+ conn: text(header.producer),
446
+ seq: num(header.seq),
447
+ at_ms: num(rec.at),
448
+ tick_from: num(ticks[0]),
449
+ tick_to: num(ticks[1]),
450
+ dt: num(rec.dt),
451
+ tick_ms: num(rec.tick),
452
+ present_ms: num(rec.present),
453
+ render_ms: num(rec.render),
454
+ sim_ms: num(rec.sim),
455
+ other_ms: num(rec.other),
456
+ wait_ms: num(rec.wait),
457
+ window_ms: num(rec.windowMs),
458
+ entities: num(rec.entities),
459
+ // What the frame asked the GPU for. The only columns that can explain a
460
+ // frame whose time is `wait` with every CPU phase small.
461
+ calls: num(rec.calls),
462
+ triangles: num(rec.triangles),
463
+ heap_delta_mb: num(rec.heapDeltaMB),
464
+ dom: num(rec.dom),
465
+ geometries: num(rec.geometries),
466
+ textures: num(rec.textures),
467
+ spawned: json(rec.spawned),
468
+ despawned: json(rec.despawned),
469
+ programs: json(rec.programs),
470
+ snapshot: json(rec.snapshot),
471
+ channels: json(rec.channels),
472
+ render: json(rec.render && typeof rec.render === 'object' ? rec.render : undefined),
473
+ traits: json(rec.traits),
474
+ kinds: json(rec.kinds),
475
+ blocking_ms: num(rec.blocking),
476
+ render_start: time(rec.renderStart),
477
+ layout_start: time(rec.styleAndLayoutStart),
478
+ scripts: kind === 'long_frame' ? json(Array.isArray(rec.scripts) ? rec.scripts : undefined) : null,
479
+ };
480
+ }
481
+
482
+ // ── long frames ─────────────────────────────────────────────────────────────
483
+ //
484
+ // Chrome's Long Animation Frames API reports, for each frame whose main-thread
485
+ // work passed 50 ms, the scripts that ran in it. The browser delivers the
486
+ // report after its frame, and it carries a time rather than a tick, so it is
487
+ // stored beside the frame records (`longFrames` on a track segment) and joined
488
+ // to them here, per browser, by time.
489
+ //
490
+ // The frame record a long frame belongs to is the one whose interval it
491
+ // overlaps most. A frame record's interval runs from the previous
492
+ // animation-frame timestamp to its own (`at - dt` to `at`), and its split is
493
+ // the work that made that interval long. A long frame spans its first task to
494
+ // the end of its paint (`at` to `at + duration`). Its `renderStart` is not a
495
+ // usable key on its own: Chrome stamps an animation frame a few milliseconds
496
+ // before the rendering it belongs to starts, so a long frame's render lands in
497
+ // one interval or the next depending on how late the task before it ran.
498
+ //
499
+ // Frame intervals are taken as never overlapping — each begins no earlier than
500
+ // the previous frame record's timestamp — which is true of a real track and
501
+ // what keeps the scan below short on a posted one that is not.
502
+
503
+ const finite = (v) => num(v) !== null;
504
+ // A long frame overlaps two or three frame records; a scan this long only
505
+ // happens on a track no browser wrote, and is cut rather than followed.
506
+ const JOIN_SCAN = 64;
507
+
508
+ /**
509
+ * Every long-frame record in a track, each with the frame record it describes.
510
+ * A long frame no frame record covers (the tab was hidden, measuring was off
511
+ * for the frame) is still listed, with `frame` null.
512
+ *
513
+ * @param {object[]} segments - track segments, any producer, any order
514
+ * @returns {{ conn: string|null, record: object, frame: object|null }[]}
515
+ */
516
+ export function joinLongFrames(segments) {
517
+ const byConn = new Map();
518
+ const of = (conn) => {
519
+ if (!byConn.has(conn)) byConn.set(conn, { frames: [], longs: [] });
520
+ return byConn.get(conn);
521
+ };
522
+ for (const seg of segments ?? []) {
523
+ const conn = text(seg?.header?.producer);
524
+ const frames = Array.isArray(seg?.frames) ? seg.frames : [];
525
+ const longs = Array.isArray(seg?.longFrames) ? seg.longFrames : [];
526
+ if (!frames.length && !longs.length) continue;
527
+ const bucket = of(conn);
528
+ for (const f of frames) if (f && typeof f === 'object' && finite(f.at) && finite(f.dt) && f.dt > 0) bucket.frames.push(f);
529
+ for (const l of longs) if (l && typeof l === 'object' && finite(l.at)) bucket.longs.push(l);
530
+ }
531
+ const out = [];
532
+ for (const [conn, { frames, longs }] of byConn) {
533
+ frames.sort((x, y) => x.at - y.at);
534
+ const from = frames.map((f, i) => Math.max(f.at - f.dt, i ? frames[i - 1].at : -Infinity));
535
+ for (const record of longs) {
536
+ const start = record.at;
537
+ const end = start + (finite(record.duration) && record.duration > 0 ? record.duration : 0);
538
+ // The first frame record whose timestamp is past the long frame's start:
539
+ // the interval the long frame begins in, or the next one.
540
+ let lo = 0;
541
+ let hi = frames.length;
542
+ while (lo < hi) {
543
+ const mid = (lo + hi) >> 1;
544
+ if (frames[mid].at > start) hi = mid; else lo = mid + 1;
545
+ }
546
+ let best = null;
547
+ if (end === start) {
548
+ // No duration to overlap with: the interval its start falls in.
549
+ if (lo < frames.length && from[lo] <= start) best = frames[lo];
550
+ } else {
551
+ // That one and every one after it that begins before the long frame
552
+ // ends; on a tie the earlier frame keeps it.
553
+ let bestOverlap = 0;
554
+ for (let i = lo; i < frames.length && i < lo + JOIN_SCAN && from[i] < end; i++) {
555
+ const overlap = Math.min(end, frames[i].at) - Math.max(start, from[i]);
556
+ if (overlap > bestOverlap) { best = frames[i]; bestOverlap = overlap; }
557
+ }
558
+ }
559
+ out.push({ conn, record, frame: best });
560
+ }
561
+ }
562
+ return out;
563
+ }
564
+
565
+ // The most ticks one `second` record can span: a second at the fastest tick
566
+ // rate a game runs, with room to spare. A longer claim is not a second.
567
+ const MAX_SECOND_TICKS = 1000;
568
+
569
+ // A second's ticks as a key: `second` records of different browsers can
570
+ // cover the same ticks, and each gets the count for its own.
571
+ const rangeKey = (from, to) => `${from}:${to}`;
572
+
573
+ /**
574
+ * How many of each entity kind the replayed world held over each second the
575
+ * performance track records — the `kind` rows' `alive` and `alive_max`. Fed
576
+ * the world once per tick as the replay walks forward (`see`); holds only one
577
+ * running sum and maximum per second and kind.
578
+ *
579
+ * @param {object[]} segments - the track segments
580
+ * @returns {{ see(tick: number, entities: {def: string}[]): void,
581
+ * stats(from: number, to: number): { ticks: number, sum: Map<string, number>, max: Map<string, number> } | null }}
582
+ */
583
+ export function aliveCounter(segments) {
584
+ // The seconds each browser claims, by browser. A track is written by a
585
+ // browser, so its claims are checked before anything is counted over them:
586
+ // a span longer than any second's, or one overlapping the same browser's
587
+ // previous second, is dropped. What is left is at most one open second per
588
+ // browser at any tick, which is what keeps counting cheap.
589
+ const claimed = new Map();
590
+ for (const seg of segments ?? []) {
591
+ const producer = text(seg?.header?.producer) ?? '';
592
+ if (!claimed.has(producer)) claimed.set(producer, []);
593
+ for (const s of Array.isArray(seg?.seconds) ? seg.seconds : []) {
594
+ const from = Array.isArray(s?.ticks) ? num(s.ticks[0]) : null;
595
+ const to = Array.isArray(s?.ticks) ? num(s.ticks[1]) : null;
596
+ if (from === null || to === null || to < from || to - from > MAX_SECOND_TICKS) continue;
597
+ claimed.get(producer).push({ from, to });
598
+ }
599
+ }
600
+ const ranges = new Map();
601
+ for (const list of claimed.values()) {
602
+ list.sort((a, b) => a.from - b.from || a.to - b.to);
603
+ let lastTo = -Infinity;
604
+ for (const { from, to } of list) {
605
+ if (from <= lastTo) continue;
606
+ lastTo = to;
607
+ const key = rangeKey(from, to);
608
+ if (!ranges.has(key)) ranges.set(key, { from, to, ticks: 0, sum: new Map(), max: new Map() });
609
+ }
610
+ }
611
+ // Seconds in the order they start, walked alongside the replay: a tick only
612
+ // touches the seconds open at it.
613
+ const pending = [...ranges.values()].sort((a, b) => a.from - b.from);
614
+ let next = 0;
615
+ let open = [];
616
+ return {
617
+ see(tick, entities) {
618
+ while (next < pending.length && pending[next].from <= tick) open.push(pending[next++]);
619
+ open = open.filter((r) => r.to >= tick);
620
+ if (!open.length) return;
621
+ const here = new Map();
622
+ for (const e of entities) here.set(e.def, (here.get(e.def) ?? 0) + 1);
623
+ for (const r of open) {
624
+ r.ticks += 1;
625
+ // A kind missing from this tick counts 0 towards its mean: every kind
626
+ // seen in the second is divided by all of the second's ticks.
627
+ for (const [kind, n] of here) {
628
+ r.sum.set(kind, (r.sum.get(kind) ?? 0) + n);
629
+ r.max.set(kind, Math.max(r.max.get(kind) ?? 0, n));
630
+ }
631
+ }
632
+ },
633
+ stats(from, to) {
634
+ const r = ranges.get(rangeKey(from, to));
635
+ return r && r.ticks ? r : null;
636
+ },
637
+ };
638
+ }
639
+
640
+ // One `second` record's `kinds` and `traits`, as a row per kind. Every kind
641
+ // the replayed world held during the second gets a row, whether or not it
642
+ // spent anything, so a count by kind is complete.
643
+ function kindRows(header, sec, alive) {
644
+ const ticks = Array.isArray(sec.ticks) && sec.ticks.length === 2 ? sec.ticks : [null, null];
645
+ const kinds = sec.kinds && typeof sec.kinds === 'object' && !Array.isArray(sec.kinds) ? sec.kinds : {};
646
+ const axes = new Map();
647
+ for (const t of Array.isArray(sec.traits) ? sec.traits : []) {
648
+ if (!t || typeof t !== 'object' || typeof t.comp !== 'string' || num(t.ms) === null) continue;
649
+ const a = axes.get(t.comp) ?? {};
650
+ if (t.axis === 'tick' || t.axis === 'present') a[t.axis] = (a[t.axis] ?? 0) + t.ms;
651
+ axes.set(t.comp, a);
652
+ }
653
+ // The world over the second's ticks, or null when the replay held none of
654
+ // them. Once counted, a kind with none is 0, not unknown.
655
+ const from = num(ticks[0]);
656
+ const to = num(ticks[1]);
657
+ const counted = from !== null && to !== null ? alive?.stats(from, to) ?? null : null;
658
+ const names = new Set([
659
+ ...Object.keys(kinds).filter((k) => kinds[k] && typeof kinds[k] === 'object'),
660
+ ...(counted?.max.keys() ?? []),
661
+ ]);
662
+ const rows = [];
663
+ for (const kind of [...names].sort()) {
664
+ const k = kinds[kind] && typeof kinds[kind] === 'object' ? kinds[kind] : {};
665
+ rows.push({
666
+ row: 'kind',
667
+ conn: text(header.producer),
668
+ seq: num(header.seq),
669
+ at_ms: num(sec.at),
670
+ tick_from: num(ticks[0]),
671
+ tick_to: num(ticks[1]),
672
+ kind: text(kind, 128),
673
+ ms: num(k.ms),
674
+ tick_ms: num(axes.get(kind)?.tick),
675
+ present_ms: num(axes.get(kind)?.present),
676
+ trait_calls: num(k.calls),
677
+ entities_spent: num(k.entities),
678
+ alive: counted ? Math.round(((counted.sum.get(kind) ?? 0) / counted.ticks) * 10) / 10 : null,
679
+ alive_max: counted ? (counted.max.get(kind) ?? 0) : null,
680
+ });
681
+ }
682
+ return rows;
683
+ }
684
+
685
+ /**
686
+ * The performance table for a session's track segments, or null when the
687
+ * session carries no track — a recording made with measuring switched off, or
688
+ * by an engine from before the track existed. Null rather than an empty table
689
+ * so the caller can say the cost was never recorded, which is a different
690
+ * answer from a session that cost nothing.
691
+ *
692
+ * @param {object[]} segments - the track segments, in producer then seq order
693
+ * @param {{ alive?: ReturnType<typeof aliveCounter> }} [opts] - how many of each
694
+ * kind the replayed world held over each second
695
+ */
696
+ export function perfTable(segments, { alive = null } = {}) {
697
+ if (!segments?.length) return null;
698
+ const rows = [];
699
+ for (const seg of segments) {
700
+ const header = seg?.header ?? {};
701
+ // Only records that ARE records: a track segment is whatever a browser
702
+ // posted, and `frames: {}` unguarded would fail the whole export over its
703
+ // optional third table.
704
+ const recs = (key) => (Array.isArray(seg?.[key]) ? seg[key] : []);
705
+ for (const f of recs('frames')) if (f && typeof f === 'object') rows.push(perfRow('frame', header, f));
706
+ for (const s of recs('seconds')) {
707
+ if (!s || typeof s !== 'object') continue;
708
+ rows.push(perfRow('second', header, s));
709
+ rows.push(...kindRows(header, s, alive));
710
+ }
711
+ }
712
+ for (const { conn, record, frame } of joinLongFrames(segments)) {
713
+ const ticks = Array.isArray(frame?.ticks) && frame.ticks.length === 2 ? frame.ticks : [null, null];
714
+ // Only a long frame's own fields: a record that also carries a frame's
715
+ // keys (`sim`, `traits`) does not get to fill those columns.
716
+ const own = {
717
+ at: record.at, dt: record.duration, blocking: record.blocking,
718
+ renderStart: record.renderStart, styleAndLayoutStart: record.styleAndLayoutStart, scripts: record.scripts,
719
+ };
720
+ rows.push(perfRow('long_frame', { producer: conn }, own, ticks));
721
+ }
722
+ if (!rows.length) return null;
723
+ return { columns: PERF_KEYS.map((name) => ({ name, type: PERF_KEY_TYPES[name] })), rows };
724
+ }
725
+
726
+ // ── the session info table ──────────────────────────────────────────────────
727
+ //
728
+ // The fourth table: what is true of the session as a whole, and of stretches
729
+ // of it, that no row of the other three can say. Which machine each browser
730
+ // measured on. Which ticks a browser measured nothing at all. Where the
731
+ // replay stopped matching what was played, so the state rows there describe a
732
+ // world nobody saw. None of it is a per-tick fact, and all of it changes what
733
+ // a per-tick number means, so it is a table a query joins rather than a line
734
+ // a reader has to remember.
735
+ //
736
+ // One table, told apart by `row`, like the performance table. Every row that
737
+ // covers ticks carries `tick_from` and `tick_to`, both inclusive, so any row
738
+ // kind joins another table the same way:
739
+ //
740
+ // LEFT JOIN info d ON d.row = 'diverged' AND s.tick BETWEEN d.tick_from AND d.tick_to
741
+ //
742
+ // The row kinds:
743
+ // session one row: the recording's span, tick rate, start time,
744
+ // player count, and which engine and game code recorded it
745
+ // against which replayed it
746
+ // player one per stay in the room: `conn`, and the ticks between
747
+ // joining and leaving
748
+ // browser one per browser that wrote a performance track: `conn` and
749
+ // the device it measured on
750
+ // unmeasured a stretch of the recording none of that browser's frames
751
+ // covered: the page was hidden, measuring was off, or its
752
+ // segments never arrived
753
+ // track_cut a browser's track stopped being stored at `tick_from`
754
+ // because the session's track reached its size limit
755
+ // diverged ticks where the replay's world is in doubt: after the last
756
+ // checksum or keyframe that matched and up to the next one
757
+ // unsupported an event the replay cannot reproduce (`what` says which);
758
+ // the world from `tick_from` on is not the one played
759
+ // missing_segment a recording segment that never reached storage; the
760
+ // inputs just before `tick_from` are missing
761
+ const INFO_COLUMNS = [
762
+ ['row', 'STRING'], ['conn', 'STRING'], ['tick_from', 'DOUBLE'], ['tick_to', 'DOUBLE'], ['what', 'STRING'],
763
+ ['stream', 'STRING'], ['started_at', 'STRING'], ['tick_rate', 'DOUBLE'], ['players', 'DOUBLE'],
764
+ ['checksums_verified', 'DOUBLE'],
765
+ ['recorded_engine', 'STRING'], ['replayed_engine', 'STRING'],
766
+ ['recorded_code', 'STRING'], ['replayed_code', 'STRING'], ['code_changed', 'BOOLEAN'],
767
+ ['recorded_build', 'STRING'], ['recorded_commit', 'STRING'], ['recorded_dirty', 'BOOLEAN'],
768
+ ['replayed_build', 'STRING'], ['build_changed', 'BOOLEAN'],
769
+ ['user_agent', 'STRING'], ['viewport_width', 'DOUBLE'], ['viewport_height', 'DOUBLE'],
770
+ ['screen_dpr', 'DOUBLE'], ['cores', 'DOUBLE'], ['touch', 'BOOLEAN'], ['long_frames', 'BOOLEAN'], ['automated', 'BOOLEAN'],
771
+ ['limit_bytes', 'DOUBLE'], ['detail', 'JSON'],
772
+ ];
773
+
774
+ // Everything below is read off files anything that reaches the dev server can
775
+ // write, so each value is kept only when it is the type its column says. Text
776
+ // loses its control characters too: the table is read in a terminal, and
777
+ // DuckDB's csv and list modes print a cell's bytes as they are, so an escape
778
+ // sequence in a user agent would repaint the screen of whoever reads it.
779
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
780
+ const text = (v, max = 512) => (typeof v === 'string' ? v.replace(CONTROL, '').slice(0, max) : null);
781
+ const flag = (v) => (typeof v === 'boolean' ? v : null);
782
+ const positiveNum = (v) => (num(v) !== null && v > 0 ? v : null);
783
+ const whenOf = (ms) => {
784
+ if (num(ms) === null) return null;
785
+ const d = new Date(ms);
786
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
787
+ };
788
+ const recordsOf = (seg, key) => (Array.isArray(seg?.[key]) ? seg[key] : []);
789
+ const tickRangeOf = (f) => {
790
+ const t = f?.ticks;
791
+ return Array.isArray(t) && num(t[0]) !== null && num(t[1]) !== null ? [t[0], t[1]] : null;
792
+ };
793
+
794
+ // A frame's tick range is what the sim stepped during it, so a frame drawn
795
+ // while the sim was between ticks leaves a tick or two uncovered in the
796
+ // ordinary course of playing. Stretches shorter than this are not reported.
797
+ const UNMEASURED_MIN_TICKS = 20;
798
+
799
+ /**
800
+ * The stretches of the recording, per browser that wrote a track, that none
801
+ * of its frames covered, as inclusive tick ranges. Only browsers that wrote
802
+ * SOMETHING: a browser with no track has no row here, and its missing
803
+ * `browser` row is what says so. A browser whose player the recording saw
804
+ * come and go is only unmeasured while that player was in the room.
805
+ *
806
+ * @param {Map<string, [number, number][]>} [staysByConn] - each player's stays
807
+ */
808
+ export function unmeasuredStretches(segments, firstTick, endTick, staysByConn = new Map(), minTicks = UNMEASURED_MIN_TICKS) {
809
+ const byConn = new Map();
810
+ for (const seg of segments ?? []) {
811
+ const conn = text(seg?.header?.producer);
812
+ const covered = byConn.get(conn) ?? [];
813
+ for (const f of recordsOf(seg, 'frames')) {
814
+ const range = tickRangeOf(f);
815
+ if (range) covered.push(range);
816
+ }
817
+ byConn.set(conn, covered);
818
+ }
819
+ const out = [];
820
+ const keep = (conn, from, to) => {
821
+ const windows = staysByConn.get(conn) ?? [[firstTick, endTick]];
822
+ for (const [a, b] of windows) {
823
+ const lo = Math.max(from, a);
824
+ const hi = Math.min(to, b);
825
+ if (hi - lo + 1 >= minTicks) out.push({ conn, from: lo, to: hi });
826
+ }
827
+ };
828
+ for (const [conn, ranges] of byConn) {
829
+ ranges.sort((a, b) => a[0] - b[0]);
830
+ // The last tick known measured; the stretch before the first frame starts
831
+ // at the session's first tick.
832
+ let cursor = firstTick - 1;
833
+ for (const [from, to] of ranges) {
834
+ if (from > cursor + 1) keep(conn, cursor + 1, from - 1);
835
+ cursor = Math.max(cursor, to);
836
+ }
837
+ if (endTick > cursor) keep(conn, cursor + 1, endTick);
838
+ }
839
+ return out;
840
+ }
841
+
842
+ /**
843
+ * Build the session info table.
844
+ *
845
+ * @param {object} o
846
+ * @param {string|null} o.stream
847
+ * @param {object} o.session - the stitched recording ({ header, events })
848
+ * @param {object} o.replayer - the replayer, AFTER it has run to the end
849
+ * @param {number} o.firstTick - the first tick the state table holds
850
+ * @param {number} o.endTick - the recording's last tick
851
+ * @param {object[]} o.segments - the performance track's segments
852
+ * @param {object|null} o.limits - what `readTrackLimits` returned
853
+ */
854
+ export function infoTable({
855
+ stream, session, replayer, firstTick, endTick, segments = [], limits = null,
856
+ recordedEngine = null, runningEngine = null, recordedCode = null, runningCode = null, codeChanged = false,
857
+ recordedBuild = null, runningBuild = null, buildChanged = null,
858
+ }) {
859
+ const rows = [];
860
+ const header = session?.header ?? {};
861
+ const span = (from, to) => ({ tick_from: num(from) ?? firstTick, tick_to: num(to) ?? endTick });
862
+
863
+ // Stays in the room. A participant in the header was there when recording
864
+ // began; after that, the recording's join and leave events say who came and
865
+ // went.
866
+ const stays = [];
867
+ const open = new Map();
868
+ const participants = header.participants && typeof header.participants === 'object' ? header.participants : {};
869
+ for (const [key, info] of Object.entries(participants)) {
870
+ const conn = text(key);
871
+ if (conn) open.set(conn, { conn, from: firstTick, info });
872
+ }
873
+ for (const e of Array.isArray(session?.events) ? session.events : []) {
874
+ const conn = text(e?.connId);
875
+ if (!conn) continue;
876
+ if (e.k === 'jn' && !open.has(conn)) open.set(conn, { conn, from: num(e.t) ?? firstTick, info: e.info });
877
+ else if (e.k === 'lv' && open.has(conn)) {
878
+ stays.push({ ...open.get(conn), to: num(e.t) ?? endTick });
879
+ open.delete(conn);
880
+ }
881
+ }
882
+ for (const stay of open.values()) stays.push({ ...stay, to: endTick });
883
+ stays.sort((a, b) => a.from - b.from);
884
+
885
+ rows.push({
886
+ row: 'session',
887
+ ...span(firstTick, endTick),
888
+ stream: text(stream),
889
+ started_at: whenOf(header.startedAt),
890
+ tick_rate: positiveNum(header.tickRate),
891
+ players: new Set(stays.map((s) => s.conn)).size,
892
+ checksums_verified: num(replayer?.checksumsVerified),
893
+ recorded_engine: recordedEngine,
894
+ replayed_engine: runningEngine,
895
+ recorded_code: recordedCode,
896
+ replayed_code: runningCode,
897
+ code_changed: codeChanged,
898
+ recorded_build: recordedBuild?.id ?? null,
899
+ recorded_commit: recordedBuild?.git?.commit ?? null,
900
+ recorded_dirty: recordedBuild?.git?.dirty ?? null,
901
+ replayed_build: runningBuild?.id ?? null,
902
+ build_changed: buildChanged,
903
+ });
904
+ for (const s of stays) rows.push({ row: 'player', conn: s.conn, ...span(s.from, s.to), detail: json(s.info ?? null) });
905
+
906
+ // The device block is stamped on every segment a browser wrote; the first
907
+ // one carrying it wins, so a segment written before the navigator was
908
+ // readable erases nothing.
909
+ const devices = new Map();
910
+ for (const seg of segments ?? []) {
911
+ const conn = text(seg?.header?.producer);
912
+ const device = seg?.header?.device && typeof seg.header.device === 'object' ? seg.header.device : null;
913
+ if (!devices.has(conn) || (!devices.get(conn) && device)) devices.set(conn, device);
914
+ }
915
+ for (const [conn, d] of devices) {
916
+ const viewport = d?.viewport && typeof d.viewport === 'object' ? d.viewport : {};
917
+ rows.push({
918
+ row: 'browser',
919
+ conn,
920
+ user_agent: text(d?.userAgent),
921
+ viewport_width: positiveNum(viewport.width),
922
+ viewport_height: positiveNum(viewport.height),
923
+ screen_dpr: positiveNum(d?.screenDpr),
924
+ cores: positiveNum(d?.cores),
925
+ touch: flag(d?.touch),
926
+ long_frames: flag(d?.longFrames),
927
+ automated: flag(d?.automated),
928
+ });
929
+ }
930
+ const staysByConn = new Map();
931
+ for (const st of stays) staysByConn.set(st.conn, [...(staysByConn.get(st.conn) ?? []), [st.from, st.to]]);
932
+ for (const g of unmeasuredStretches(segments, firstTick, endTick, staysByConn)) {
933
+ rows.push({ row: 'unmeasured', conn: g.conn, tick_from: g.from, tick_to: g.to });
934
+ }
935
+ for (const cut of limits?.cuts ?? []) {
936
+ rows.push({
937
+ row: 'track_cut', conn: text(cut.producer), what: text(cut.kind),
938
+ ...span(cut.fromTick, endTick), limit_bytes: positiveNum(limits.limitBytes),
939
+ });
940
+ }
941
+
942
+ // A divergence is found at a checksum, some ticks after whatever caused it.
943
+ // The ticks in doubt start after the last tick playback matched (`since`)
944
+ // and end at the next one it matched again (`until`). A state row at tick t
945
+ // is the world BEFORE tick t's events, so the end depends on what matched:
946
+ // a checksum that verifies at t vouches for row t, and the range ends at
947
+ // t - 1; a keyframe restore that changed the world at t (a re-anchor)
948
+ // leaves row t drifted, and the range ends at t. An engine whose replayer
949
+ // does not say `since` or `until` is read the conservative way: from the
950
+ // start of the session, up to the first re-anchor at or after the
951
+ // divergence. Divergences that come out with one range are one row.
952
+ const reanchorTicks = new Set((replayer?.reanchors ?? []).map((r) => num(r?.tick)).filter((t) => t !== null));
953
+ const byRange = new Map();
954
+ for (const d of replayer?.divergences ?? []) {
955
+ const found = num(d?.tick);
956
+ let from = 'since' in (d ?? {}) ? (num(d.since) === null ? firstTick : d.since + 1) : firstTick;
957
+ if (found !== null) from = Math.min(from, found);
958
+ let to;
959
+ if ('until' in (d ?? {})) {
960
+ const until = num(d.until);
961
+ to = until === null ? endTick : reanchorTicks.has(until) ? until : until - 1;
962
+ } else {
963
+ to = [...reanchorTicks].filter((t) => found !== null && t >= found).sort((a, b) => a - b)[0] ?? endTick;
964
+ }
965
+ to = Math.max(from, to);
966
+ const key = `${from}:${to}`;
967
+ const seen = byRange.get(key);
968
+ if (seen) {
969
+ seen.checks += num(d?.checks) ?? 1;
970
+ continue;
971
+ }
972
+ byRange.set(key, {
973
+ from, to, via: text(d?.via) ?? 'checksum',
974
+ found_at: found, checks: num(d?.checks) ?? 1, expected: d?.expected ?? null, got: d?.got ?? null,
975
+ });
976
+ }
977
+ for (const r of byRange.values()) {
978
+ rows.push({
979
+ row: 'diverged', what: r.via, tick_from: r.from, tick_to: r.to,
980
+ detail: json({ found_at: r.found_at, checks: r.checks, expected: r.expected, got: r.got }),
981
+ });
982
+ }
983
+ for (const u of replayer?.unsupported ?? []) {
984
+ const { tick, kind, ...rest } = u ?? {};
985
+ rows.push({ row: 'unsupported', what: text(kind), ...span(tick, endTick), detail: json(rest) });
986
+ }
987
+ // The inputs a missing segment held never reached the replay, so nothing
988
+ // from where it was on is the world played. A later checksum that verified
989
+ // would narrow this, and the recording keeps no list of those.
990
+ for (const g of replayer?.gaps ?? []) {
991
+ rows.push({
992
+ row: 'missing_segment', what: `seq ${num(g?.afterSeq) ?? '?'}→${num(g?.beforeSeq) ?? '?'}`,
993
+ ...span(g?.atTick, endTick),
994
+ });
995
+ }
996
+ return { columns: INFO_COLUMNS.map(([name, type]) => ({ name, type })), rows };
997
+ }
998
+
999
+ function schemaElements(columns) {
1000
+ return [
1001
+ { name: 'root', num_children: columns.length },
1002
+ ...columns.map((c) => ({ name: c.name, repetition_type: 'OPTIONAL', ...PARQUET_TYPE[c.type] })),
1003
+ ];
1004
+ }
1005
+
1006
+ // A row-at-a-time front end over a columnar writer: rows go in, and every time
1007
+ // `rowsPerGroup` of them have arrived the batch is handed over as one row group
1008
+ // and dropped. Nothing accumulates across groups, which is the whole point —
1009
+ // materialising every column of every tick before writing is what made the
1010
+ // prototype cost 19 s and a gigabyte for a 27-second session.
1011
+ // A row group is held whole in memory before it is encoded, and its cost is
1012
+ // rows x COLUMNS — so a fixed row count means a 40-column game and a
1013
+ // 5,000-column one buffer two orders of magnitude apart. Budgeting cells keeps
1014
+ // the ceiling flat whatever the table's width. An explicit rowsPerGroup (a
1015
+ // test, a caller who knows) is obeyed as given.
1016
+ export const CELL_BUDGET = 8_000_000;
1017
+ export const DEFAULT_ROWS_PER_GROUP = 20_000;
1018
+
1019
+ function groupFor(columns, requested) {
1020
+ if (requested != null) return requested;
1021
+ return Math.max(500, Math.min(DEFAULT_ROWS_PER_GROUP, Math.floor(CELL_BUDGET / Math.max(1, columns.length))));
1022
+ }
1023
+
1024
+ // Written under a temporary name and moved into place only once the file is
1025
+ // complete. The writer streams straight to disk, so a throw partway through
1026
+ // would otherwise leave a truncated file sitting at the canonical name — every
1027
+ // later read of which fails with "not a parquet file", and the previous good
1028
+ // export of that session is already gone. A partial answer under the name of a
1029
+ // whole one is the worst of the three outcomes.
1030
+ function rowSink({ file, columns, rowsPerGroup }) {
1031
+ const partial = `${file}.partial`;
1032
+ const writer = fileWriter(partial);
1033
+ const pq = new ParquetWriter({ writer, schema: schemaElements(columns) });
1034
+ // Name → column position, so a row is placed by index rather than by looking
1035
+ // each of the table's columns up on it. The table is WIDE and each row is
1036
+ // narrow — 443 columns, ~49 of them set on any given entity — so reading
1037
+ // every column name off every row costs a property lookup for the ~90% that
1038
+ // are absent. Filling nulls and writing only what is there is the same table
1039
+ // for about a third of the work.
1040
+ const at = new Map(columns.map((c, i) => [c.name, i]));
1041
+ let batch = columns.map(() => []);
1042
+ let rows = 0;
1043
+ const flush = () => {
1044
+ if (!batch[0].length) return;
1045
+ pq.write({
1046
+ columnData: columns.map((c, i) => ({ name: c.name, data: batch[i] })),
1047
+ // One group per batch: the batch IS the group, so the writer is never
1048
+ // asked to hold more than what has already been handed to it.
1049
+ rowGroupSize: batch[0].length,
1050
+ });
1051
+ batch = columns.map(() => []);
1052
+ };
1053
+ return {
1054
+ push(row) {
1055
+ const index = batch[0].length;
1056
+ for (let i = 0; i < columns.length; i++) batch[i].push(null);
1057
+ for (const name in row) {
1058
+ const i = at.get(name);
1059
+ if (i !== undefined && row[name] !== undefined) batch[i][index] = row[name];
1060
+ }
1061
+ rows += 1;
1062
+ if (batch[0].length >= rowsPerGroup) flush();
1063
+ },
1064
+ finish() {
1065
+ flush();
1066
+ pq.finish();
1067
+ renameSync(partial, file);
1068
+ return rows;
1069
+ },
1070
+ // Called when the run did not get that far. Best-effort: a leftover
1071
+ // `.partial` is untidy, an unreadable `.parquet` is a wrong answer.
1072
+ abandon() {
1073
+ try { rmSync(partial, { force: true }); } catch { /* nothing to clean up */ }
1074
+ },
1075
+ };
1076
+ }
1077
+
1078
+ /** A streaming Parquet writer for a table of `columns`, sized as the export sizes its own. */
1079
+ export function rowSinkFor(file, columns, rowsPerGroup) {
1080
+ return rowSink({ file, columns, rowsPerGroup: groupFor(columns, rowsPerGroup) });
1081
+ }
1082
+
1083
+ /**
1084
+ * Where the engine's headless replay door lives, in the engine THIS GAME is
1085
+ * pinned to.
1086
+ *
1087
+ * Imported from the resolved bundle rather than by a relative path, for the
1088
+ * same reason the recording's reassembler is: a game can sit on a new CLI and
1089
+ * an older engine, and the honest answer to that is a sentence saying so — not
1090
+ * a module-resolution stack trace that reads, to the person holding it, as "my
1091
+ * recording is broken".
1092
+ */
1093
+ export async function loadOpenReplay(sharedDir, forWhat = 'export') {
1094
+ const at = join(sharedDir, 'framework', 'replay-open.mjs');
1095
+ try {
1096
+ return (await import(pathToFileURL(at).href)).openReplay;
1097
+ } catch (e) {
1098
+ throw new Error(
1099
+ `this game's pinned engine cannot open a recording headlessly, so there is nothing to ${forWhat} — `
1100
+ + `run \`joystack update\` and try again (${e?.message ?? e})`,
1101
+ );
1102
+ }
1103
+ }
1104
+
1105
+ // A version or code fingerprint as a recording's header states it, or null
1106
+ // when the value is not one. A header is not first-party text (see
1107
+ // `exportSession`), so a value that is not a plain version-or-digest token is
1108
+ // treated as unknown — a state every reader already handles honestly — rather
1109
+ // than sanitized and printed. The charset covers semver including pre-release
1110
+ // labels (`0.2.0-riven`) and hex digests, and admits no control character,
1111
+ // escape or whitespace.
1112
+ //
1113
+ // `__PUBLISH_VERSION__` is the placeholder the release build substitutes. A
1114
+ // room served straight off a checkout never went through that build, so every
1115
+ // session recorded under `joystack dev` carries the token verbatim. Read as a
1116
+ // version it would print garbage, and would report a mismatch on every single
1117
+ // dev export — a warning that fires every time is one nobody reads by the
1118
+ // third time.
1119
+ const VERSION_TOKEN = /^[A-Za-z0-9._+-]{1,64}$/;
1120
+ export const knownHeaderToken = (v) =>
1121
+ (typeof v === 'string' && VERSION_TOKEN.test(v) && v !== '__PUBLISH_VERSION__' ? v : null);
1122
+
1123
+ /**
1124
+ * Export one recorded session as Parquet tables: state, inputs,
1125
+ * performance (when the session was measured), session info, and the fields
1126
+ * table that says what the state columns and channels are.
1127
+ *
1128
+ * The state table is the expensive one and the reason this exists: a recording
1129
+ * holds inputs, so the only way to know where anything WAS is to run the game
1130
+ * forward again. Doing that once and writing the result down is what turns
1131
+ * every later question into SQL instead of another replay.
1132
+ *
1133
+ * @param {object} opts
1134
+ * @param {string} opts.dir - the game folder
1135
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
1136
+ * @param {string} opts.outDir - where the .parquet files go
1137
+ * @param {string} [opts.sharedDir] - the resolved engine bundle's shared/ tree
1138
+ * @param {number} [opts.rowsPerGroup]
1139
+ * @param {string} [opts.engineVersion] - the engine this export is running on,
1140
+ * compared against the one that recorded the session
1141
+ * @param {(o: object) => Promise<object>} [opts.openReplayImpl] - seam for tests
1142
+ * @param {(msg: string) => void} [opts.log]
1143
+ */
1144
+ export async function exportSession({
1145
+ dir, stream, outDir, sharedDir, rowsPerGroup, engineVersion, openReplayImpl, log = () => {},
1146
+ }) {
1147
+ const openReplay = openReplayImpl ?? await loadOpenReplay(sharedDir);
1148
+ // The engine's own warnings — a recording missing segments, a session tuned
1149
+ // live mid-play — are written to this sink. Without it they go to the floor
1150
+ // and the export looks clean while describing a world that is not the one
1151
+ // that was played.
1152
+ const opened = await openReplay({ dir, stream, log });
1153
+ const { replayer, bundle, session } = opened;
1154
+ const chosen = opened.stream ?? stream ?? null;
1155
+
1156
+ // Which engine wrote this recording, against the one about to replay it. The
1157
+ // header has carried engineVersion since the first recording and nothing read
1158
+ // it, so the only way a reader learned the two disagreed was a divergence —
1159
+ // which does not fire if the change altered nothing the checksums cover.
1160
+ //
1161
+ // Reported before the replay runs, not after: the replay is the slow part and
1162
+ // this answer is already known. An absent version is UNKNOWN, not different —
1163
+ // every recording made before this shipped has none, and warning on those
1164
+ // would fire on every old session still on disk.
1165
+ // A recording's header is NOT first-party text. Segments reach the dev server
1166
+ // over a lane that deliberately does not require an Origin (a multiplayer
1167
+ // room's worker sends none), and the store validates only the four fields it
1168
+ // routes on — format, seq, stream, events. Everything else is spread through
1169
+ // the stitcher verbatim, so anything printed from a header is attacker-
1170
+ // reachable by whoever can post a segment.
1171
+ //
1172
+ // Printing is what makes that matter. `ESC[2J ESC[H` clears the screen and
1173
+ // repaints, and the line being repainted here is the one a creator — or the
1174
+ // coding agent reading their terminal — uses to decide whether to trust the
1175
+ // exported numbers. An export with no session named takes the newest
1176
+ // recording, so a segment posted a moment ago is the default target.
1177
+ //
1178
+ // So: REJECT, don't sanitize — `knownHeaderToken` above.
1179
+ // The build stamp the dev server wrote beside the recording (replay-store.mjs,
1180
+ // "the build stamp"), and the build this folder holds now. Both are absent
1181
+ // for a recording copied in without its stamp or made before stamps
1182
+ // existed, and then nothing is claimed about whether the files changed.
1183
+ const recordedBuild = chosen ? readBuildStamp(dir, chosen) : null;
1184
+ let runningBuild = null;
1185
+ try { runningBuild = gameBuild(dir, { git: false }); } catch { /* not a folder of game files */ }
1186
+ const buildChanged = recordedBuild?.id && runningBuild?.id ? recordedBuild.id !== runningBuild.id : null;
1187
+ // A room served by `joystack dev` carries a placeholder where a published one
1188
+ // carries its engine version; the dev server's stamp names the engine it
1189
+ // served.
1190
+ const recordedEngine = knownHeaderToken(session?.header?.engineVersion) ?? recordedBuild?.engine ?? null;
1191
+ const runningEngine = knownHeaderToken(engineVersion);
1192
+ if (recordedEngine && runningEngine && recordedEngine !== runningEngine) {
1193
+ log(` ⚠ this session was recorded by engine ${recordedEngine} and is being replayed on `
1194
+ + `${runningEngine}. A replay runs the code as it is now, so where the two engines `
1195
+ + 'simulate differently the table holds a world nobody played.');
1196
+ }
1197
+ // And the same question for the game's own code, which is the half that
1198
+ // actually moves. engineVersion changes once per release; a fingerprint
1199
+ // changes the moment a tick does.
1200
+ // Same provenance, same rule: this one comes off the header too.
1201
+ const recordedCode = knownHeaderToken(session?.header?.codeFingerprint);
1202
+ const runningCode = knownHeaderToken(bundle?.fingerprint);
1203
+ const codeChanged = Boolean(recordedCode && runningCode && recordedCode !== runningCode);
1204
+ if (codeChanged) {
1205
+ log(' ⚠ this game has changed since the session was recorded (the simulation it lowers to '
1206
+ + `is ${runningCode}; the recording was made by ${recordedCode}). The table below is what `
1207
+ + 'your game does with those inputs TODAY, which is not what happened when you played.');
1208
+ } else if (buildChanged) {
1209
+ // The files moved while the simulation's shape did not: a number inside a
1210
+ // tick, a helper's body. The shape check cannot see that; the build can.
1211
+ log(` ⚠ this game's files have changed since the session was recorded (build ${recordedBuild.id}, `
1212
+ + `now ${runningBuild.id}), though not the shape of its simulation. The table below is what your `
1213
+ + 'game does with those inputs TODAY, which may not be what happened when you played.');
1214
+ }
1215
+
1216
+ const { columns, conflicts } = tableSchema(bundle);
1217
+ if (conflicts.length) {
1218
+ log(`note: ${conflicts.length} field name(s) are declared with different types by different entity kinds `
1219
+ + `and ride as JSON text — ${conflicts.join(', ')}`);
1220
+ }
1221
+ const columnIndex = new Map(columns.map((c) => [c.name, c.type]));
1222
+
1223
+ mkdirSync(outDir, { recursive: true });
1224
+ const stateFile = join(outDir, `${chosen ?? 'session'}.state.parquet`);
1225
+ const inputFile = join(outDir, `${chosen ?? 'session'}.inputs.parquet`);
1226
+
1227
+ // Read before the replay: the seconds its `second` records cover are where
1228
+ // the replay counts each kind alive, for the perf table's `kind` rows.
1229
+ const segments = chosen ? readTrackSegments(dir, chosen) : [];
1230
+ const alive = aliveCounter(segments);
1231
+
1232
+ const sink = rowSinkFor(stateFile, columns, rowsPerGroup);
1233
+ const tally = { undoubleable: 0 };
1234
+ let ticks = 0;
1235
+ let firstTick = null;
1236
+ let lastTick = null;
1237
+ let rows;
1238
+ try {
1239
+ // The world BEFORE any step is a real tick of the session — it is the
1240
+ // keyframe the replay entered at — so it is written before the loop, not
1241
+ // skipped into it.
1242
+ for (;;) {
1243
+ const { tick, entities } = replayer.runtime.serializeEntities();
1244
+ firstTick ??= tick;
1245
+ lastTick = tick;
1246
+ for (const entity of entities) sink.push(stateRow(entity, tick, columnIndex, tally));
1247
+ alive.see(tick, entities);
1248
+ ticks += 1;
1249
+ if (replayer.atEnd) break;
1250
+ replayer.step();
1251
+ }
1252
+ rows = sink.finish();
1253
+ } catch (e) {
1254
+ sink.abandon();
1255
+ throw e;
1256
+ }
1257
+ // The new state table is in place, so a previous export's perf and info
1258
+ // tables of this session no longer describe it. They go now rather than
1259
+ // being overwritten later: the perf table is written only when there is a
1260
+ // track, and a run that fails before the info table is written would leave
1261
+ // the old one beside the new state. A run that fails before this point
1262
+ // leaves the previous export whole.
1263
+ for (const name of ['perf', 'info', 'fields']) rmSync(join(outDir, `${chosen ?? 'session'}.${name}.parquet`), { force: true });
1264
+
1265
+ // Streamed like the state table rather than materialised: the row count comes
1266
+ // from a recording, and a recording is not this machine's to trust.
1267
+ const inputs = inputSchema(session);
1268
+ const inputSink = rowSinkFor(inputFile, inputs.columns, rowsPerGroup);
1269
+ let inputRows;
1270
+ try {
1271
+ for (const send of inputs.sends) inputSink.push(inputRow(send, inputs.keys, inputs.types));
1272
+ inputRows = inputSink.finish();
1273
+ } catch (e) {
1274
+ inputSink.abandon();
1275
+ throw e;
1276
+ }
1277
+ // The third table: what the session cost on the device that played it
1278
+ // (project note ofta45). Read straight off the track segments — nothing here
1279
+ // is replayed, because cost is the one thing a replay cannot reproduce.
1280
+ const perf = perfTable(segments, { alive });
1281
+ let perfFile = null;
1282
+ let perfRows = 0;
1283
+ if (perf) {
1284
+ perfFile = join(outDir, `${chosen ?? 'session'}.perf.parquet`);
1285
+ const perfSink = rowSinkFor(perfFile, perf.columns, rowsPerGroup);
1286
+ try {
1287
+ for (const row of perf.rows) perfSink.push(row);
1288
+ perfRows = perfSink.finish();
1289
+ } catch (e) {
1290
+ perfSink.abandon();
1291
+ throw e;
1292
+ }
1293
+ }
1294
+
1295
+ // The fourth table, written last because two of its row kinds — where the
1296
+ // replay diverged, what it could not reproduce — are only known once the
1297
+ // replay has run to the end.
1298
+ const info = infoTable({
1299
+ stream: chosen, session, replayer,
1300
+ firstTick: firstTick ?? 0,
1301
+ endTick: num(session?.endTick) ?? lastTick ?? 0,
1302
+ segments,
1303
+ limits: chosen ? readTrackLimits(dir, chosen) : null,
1304
+ recordedEngine, runningEngine, recordedCode, runningCode, codeChanged,
1305
+ recordedBuild, runningBuild, buildChanged,
1306
+ });
1307
+ const infoFile = join(outDir, `${chosen ?? 'session'}.info.parquet`);
1308
+ const infoSink = rowSinkFor(infoFile, info.columns, rowsPerGroup);
1309
+ let infoRows;
1310
+ try {
1311
+ for (const row of info.rows) infoSink.push(row);
1312
+ infoRows = infoSink.finish();
1313
+ } catch (e) {
1314
+ infoSink.abandon();
1315
+ throw e;
1316
+ }
1317
+
1318
+ // What each state column is. Read off the lowered game, like the state
1319
+ // table's schema, so it describes the columns the state table actually has.
1320
+ const fields = fieldsTable(bundle, { dir, sharedDir });
1321
+ const fieldsFile = join(outDir, `${chosen ?? 'session'}.fields.parquet`);
1322
+ const fieldsSink = rowSinkFor(fieldsFile, fields.columns, rowsPerGroup);
1323
+ let fieldsRows;
1324
+ try {
1325
+ for (const row of fields.rows) fieldsSink.push(row);
1326
+ fieldsRows = fieldsSink.finish();
1327
+ } catch (e) {
1328
+ fieldsSink.abandon();
1329
+ throw e;
1330
+ }
1331
+
1332
+ if (inputs.truncatedKeys) {
1333
+ log(`note: ${inputs.truncatedKeys} rarely-used payload field(s) did not get their own column `
1334
+ + `(the cap is ${MAX_PAYLOAD_COLUMNS}) — they are still in the \`payload\` JSON column on every row`);
1335
+ }
1336
+
1337
+ return {
1338
+ stream: chosen,
1339
+ stateFile,
1340
+ inputFile,
1341
+ // Null when the session carries no performance track: measuring was off,
1342
+ // or the recording predates the track. The caller says so rather than
1343
+ // implying the session was free.
1344
+ perfFile,
1345
+ perfRows,
1346
+ infoFile,
1347
+ infoRows,
1348
+ fieldsFile,
1349
+ fieldsRows,
1350
+ ticks,
1351
+ rows,
1352
+ columns: columns.length,
1353
+ inputs: inputRows,
1354
+ truncatedKeys: inputs.truncatedKeys,
1355
+ // Three separate ways the table can describe a world that is not the one
1356
+ // that was played, kept apart because they have different causes and
1357
+ // different answers. The ticks each covers are in the info table.
1358
+ divergences: replayer.divergences?.length ?? 0,
1359
+ // One per `diverged` row: the ticks one drift covers, how it was found,
1360
+ // and how many checksums failed inside it.
1361
+ drifts: info.rows.filter((r) => r.row === 'diverged').map((r) => {
1362
+ let checks;
1363
+ const detail = r.detail && typeof r.detail === 'object' ? r.detail : null;
1364
+ checks = Number.isSafeInteger(detail?.checks) ? detail.checks : null;
1365
+ return { from: r.tick_from, to: r.tick_to, via: r.what, checks };
1366
+ }),
1367
+ gaps: replayer.gaps?.length ?? 0,
1368
+ unsupported: replayer.unsupported?.length ?? 0,
1369
+ recordedEngine,
1370
+ runningEngine,
1371
+ recordedCode,
1372
+ runningCode,
1373
+ codeChanged,
1374
+ recordedBuild: recordedBuild?.id ?? null,
1375
+ runningBuild: runningBuild?.id ?? null,
1376
+ buildChanged,
1377
+ undoubleable: tally.undoubleable,
1378
+ };
1379
+ }