@looop-games/cli 0.1.34 → 0.1.36

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.
@@ -0,0 +1,515 @@
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
+ // Two tables come out:
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
+ //
17
+ // The state table is wide and mostly repeated (a real session measured 8.7% of
18
+ // rows and 1.3% of cells changing between consecutive ticks). Delta-encoding it
19
+ // was considered and rejected: Parquet's columnar compression already absorbs
20
+ // the redundancy — 225 MB of JSONL becomes ~3 MB — and deltas would push
21
+ // last-value-carried-forward semantics into every query anybody writes.
22
+ import { mkdirSync, renameSync, rmSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { ParquetWriter, fileWriter } from 'hyparquet-writer';
26
+
27
+ // The columns every state row carries, before any declared field. `def` is a
28
+ // column rather than a prefix on every field name — which is what lets a query
29
+ // say `WHERE def IN ('prop','saucer')` and read one `body_x`.
30
+ export const STATE_KEYS = ['tick', 'id', 'def', 'owner'];
31
+ const STATE_KEY_TYPES = { tick: 'DOUBLE', id: 'STRING', def: 'STRING', owner: 'STRING' };
32
+
33
+ // The columns every input row carries.
34
+ // `tick` is when a send ARRIVED. A predicting client stamps `targetTick` and the
35
+ // room holds the send in its jitter buffer until exactly that tick, so for any
36
+ // predicted input the tick it arrives at is not the tick its effect shows up at
37
+ // in the state table. Without this column a join on `tick` mis-attributes every
38
+ // one of them, silently.
39
+ const INPUT_KEYS = ['tick', 'target_tick', 'conn', 'seq', 'entity', 'channel', 'payload'];
40
+ const INPUT_KEY_TYPES = {
41
+ tick: 'DOUBLE', target_tick: 'DOUBLE', conn: 'STRING', seq: 'DOUBLE',
42
+ entity: 'STRING', channel: 'STRING', payload: 'JSON',
43
+ };
44
+
45
+ /**
46
+ * The logical column type a JS value implies.
47
+ *
48
+ * DOUBLE for every number, never INT: the engine has one numeric type (a JS
49
+ * double), and a column typed from whole-looking values is a column that throws
50
+ * a thousand rows later when a fraction arrives. JSON for anything that is not
51
+ * flat, and for anything that says nothing — a cell has to be a scalar, so a
52
+ * list or an object rides as text rather than being dropped.
53
+ */
54
+ function typeOf(value) {
55
+ if (value === null || value === undefined) return 'JSON';
56
+ switch (typeof value) {
57
+ case 'number': return 'DOUBLE';
58
+ case 'boolean': return 'BOOLEAN';
59
+ case 'string': return 'STRING';
60
+ default: return 'JSON';
61
+ }
62
+ }
63
+
64
+ // Two declarations of one column name that disagree. One Parquet column cannot
65
+ // be both a double and a string, and dropping it would lose both kinds' data
66
+ // silently — so the column widens to the one type that holds anything.
67
+ const merge = (a, b) => (a === b ? a : 'JSON');
68
+
69
+ /**
70
+ * The state table's shape, read off the lowered bundle.
71
+ *
72
+ * Types come from what each field was DECLARED as, never from a sample of what
73
+ * rows happen to hold. That is the whole reason this reads the manifests rather
74
+ * than the world: a sample is a guess that is right until it is not, and the
75
+ * failure lands mid-write, after the replay has already run.
76
+ *
77
+ * @param {{ manifests: Array<{component: string, owns: object}> }} bundle
78
+ * @returns {{ columns: Array<{name: string, type: string}>, conflicts: string[] }}
79
+ */
80
+ export function tableSchema(bundle) {
81
+ const declared = new Map();
82
+ const conflicts = new Set();
83
+ for (const manifest of bundle?.manifests ?? []) {
84
+ for (const [field, spec] of Object.entries(manifest.owns ?? {})) {
85
+ const type = typeOf(spec?.default);
86
+ if (!declared.has(field)) {
87
+ declared.set(field, type);
88
+ continue;
89
+ }
90
+ const merged = merge(declared.get(field), type);
91
+ if (merged !== declared.get(field) || merged !== type) conflicts.add(field);
92
+ declared.set(field, merged);
93
+ }
94
+ }
95
+ return {
96
+ columns: [
97
+ ...STATE_KEYS.map((name) => ({ name, type: STATE_KEY_TYPES[name] })),
98
+ // Sorted so the same game always exports the same column order, whatever
99
+ // order its definitions happened to be lowered in.
100
+ ...[...declared.keys()].sort().map((name) => ({ name, type: declared.get(name) })),
101
+ ],
102
+ conflicts: [...conflicts].sort(),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * One cell, as its column's type — or null.
108
+ *
109
+ * A value that does not fit is null rather than coerced. Turning a string into
110
+ * 0, or a NaN into "some number", would put a value in the table that the
111
+ * session never held, and the whole point of this table is that a question
112
+ * asked of it gets the sim's real answer.
113
+ */
114
+ export function coerce(value, type) {
115
+ if (value === null || value === undefined) return null;
116
+ switch (type) {
117
+ // Number.isFinite rejects NaN and ±Infinity: Parquet has no way to carry
118
+ // them as doubles that any reader agrees on.
119
+ case 'DOUBLE': return typeof value === 'number' && Number.isFinite(value) ? value : null;
120
+ case 'BOOLEAN': return typeof value === 'boolean' ? value : null;
121
+ case 'STRING': return typeof value === 'string' ? value : null;
122
+ case 'JSON':
123
+ try { return JSON.stringify(value) ?? null; } catch { return null; }
124
+ default: return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * One state row: the fixed keys, then every declared field this entity holds.
130
+ *
131
+ * A record key is `<kind>.<field>`; the kind is dropped because `def` already
132
+ * carries it. A key the schema does not know is skipped rather than added —
133
+ * Parquet needs its columns fixed before the first row is written, so a column
134
+ * discovered mid-session could not be honoured anyway.
135
+ */
136
+ export function stateRow(entity, tick, columnIndex, tally) {
137
+ // The key columns go through `coerce` like every other cell. They used to be
138
+ // assigned straight through, so an id that was not a string reached the
139
+ // writer as one and threw a raw type error — after the entire replay had
140
+ // already been paid for.
141
+ const row = {
142
+ tick: coerce(tick, 'DOUBLE'),
143
+ id: coerce(entity.id, 'STRING'),
144
+ def: coerce(entity.def, 'STRING'),
145
+ owner: coerce(entity.owner, 'STRING'),
146
+ };
147
+ for (const [key, value] of Object.entries(entity.record ?? {})) {
148
+ const field = key.slice(key.indexOf('.') + 1);
149
+ const type = columnIndex.get(field);
150
+ if (!type) continue;
151
+ row[field] = coerce(value, type);
152
+ // NaN and ±Infinity are real values a world can hold (M2 carries them out
153
+ // of band through a keyframe for exactly that reason) and Parquet has no
154
+ // double that means them. Null is the right answer and a SILENT null is
155
+ // not: null already means "this kind has no such field", so a dropped NaN
156
+ // would be indistinguishable from an absent column.
157
+ if (tally && row[field] === null && type === 'DOUBLE' && typeof value === 'number') tally.undoubleable += 1;
158
+ }
159
+ return row;
160
+ }
161
+
162
+ /**
163
+ * The input table: one row per accepted wire send.
164
+ *
165
+ * No replay is needed — the recording already holds these as rows. A payload
166
+ * has no declared shape, so unlike the state table its columns are typed by
167
+ * reading EVERY event first. That is not the sampling rule 1 forbids: the whole
168
+ * list is in hand before a row is written, so this is an exact read of the data
169
+ * rather than a guess from its first few values.
170
+ *
171
+ * Each scalar payload key becomes `p_<key>`, and the whole payload also rides
172
+ * as JSON — so a question about a field that did not flatten is still one
173
+ * `json_extract` away rather than a re-export.
174
+ */
175
+ // How many payload keys may become their own column.
176
+ //
177
+ // A recording is UNTRUSTED input. The segment endpoint takes what a room sends
178
+ // and cannot demand an Origin (a multiplayer room posts from a worker that has
179
+ // none), so anything that can reach the dev server can append events — and one
180
+ // column per distinct key, uncapped, turns a 151 KB post into a multi-gigabyte
181
+ // out-of-memory abort that names neither the export nor the recording.
182
+ //
183
+ // Capping loses nothing: the whole payload is on every row as JSON anyway, so a
184
+ // question about a rare key is one `json_extract` away instead of a column. The
185
+ // keys that survive are the ones that appear MOST OFTEN, because a key on every
186
+ // event is the one worth a column and a key on one event is the one that is
187
+ // not.
188
+ const MAX_PAYLOAD_COLUMNS = 256;
189
+
190
+ export function inputSchema(session) {
191
+ const sends = (session?.events ?? []).filter((e) => e?.k === 'w');
192
+ const payloadTypes = new Map();
193
+ const seen = new Map();
194
+ for (const send of sends) {
195
+ for (const [key, value] of Object.entries(send.payload ?? {})) {
196
+ const type = typeOf(value);
197
+ payloadTypes.set(key, payloadTypes.has(key) ? merge(payloadTypes.get(key), type) : type);
198
+ seen.set(key, (seen.get(key) ?? 0) + 1);
199
+ }
200
+ }
201
+ const ranked = [...seen.keys()].sort((a, b) => (seen.get(b) - seen.get(a)) || a.localeCompare(b));
202
+ // Sorted by name after ranking, so the same recording always exports the same
203
+ // column order.
204
+ const keys = ranked.slice(0, MAX_PAYLOAD_COLUMNS).sort();
205
+ const columns = [
206
+ ...INPUT_KEYS.map((name) => ({ name, type: INPUT_KEY_TYPES[name] })),
207
+ ...keys.map((k) => ({ name: `p_${k}`, type: payloadTypes.get(k) })),
208
+ ];
209
+ return { columns, keys, types: payloadTypes, sends, truncatedKeys: ranked.length - keys.length };
210
+ }
211
+
212
+ /** One input row: the fixed columns, then each flattened payload key. */
213
+ export function inputRow(send, keys, types) {
214
+ const row = {
215
+ tick: coerce(send.t, 'DOUBLE'),
216
+ // Null, not a copy of `tick`: an input with no target tick was never
217
+ // held, and repeating arrival here would invent a fact.
218
+ target_tick: coerce(send.targetTick, 'DOUBLE'),
219
+ conn: coerce(send.connId, 'STRING'),
220
+ seq: coerce(send.seq, 'DOUBLE'),
221
+ entity: coerce(send.entityId, 'STRING'),
222
+ channel: coerce(send.channel, 'STRING'),
223
+ payload: coerce(send.payload ?? null, 'JSON'),
224
+ };
225
+ // Every column on every row, so a field an event did not carry reads as
226
+ // null rather than being absent — a Parquet column has one length.
227
+ for (const k of keys) row[`p_${k}`] = coerce(send.payload?.[k], types.get(k));
228
+ return row;
229
+ }
230
+
231
+ export function inputTable(session) {
232
+ const { columns, keys, types, sends, truncatedKeys } = inputSchema(session);
233
+ return { columns, rows: sends.map((send) => inputRow(send, keys, types)), truncatedKeys };
234
+ }
235
+
236
+ // The logical types above, as the Parquet schema elements the writer wants.
237
+ // Every column is OPTIONAL because the table is deliberately SPARSE: a kind
238
+ // that does not declare a field leaves it null on that row, and null is a
239
+ // different answer from zero.
240
+ const PARQUET_TYPE = {
241
+ DOUBLE: { type: 'DOUBLE' },
242
+ BOOLEAN: { type: 'BOOLEAN' },
243
+ STRING: { type: 'BYTE_ARRAY', converted_type: 'UTF8' },
244
+ JSON: { type: 'BYTE_ARRAY', converted_type: 'JSON' },
245
+ };
246
+
247
+ function schemaElements(columns) {
248
+ return [
249
+ { name: 'root', num_children: columns.length },
250
+ ...columns.map((c) => ({ name: c.name, repetition_type: 'OPTIONAL', ...PARQUET_TYPE[c.type] })),
251
+ ];
252
+ }
253
+
254
+ // A row-at-a-time front end over a columnar writer: rows go in, and every time
255
+ // `rowsPerGroup` of them have arrived the batch is handed over as one row group
256
+ // and dropped. Nothing accumulates across groups, which is the whole point —
257
+ // materialising every column of every tick before writing is what made the
258
+ // prototype cost 19 s and a gigabyte for a 27-second session.
259
+ // A row group is held whole in memory before it is encoded, and its cost is
260
+ // rows x COLUMNS — so a fixed row count means a 40-column game and a
261
+ // 5,000-column one buffer two orders of magnitude apart. Budgeting cells keeps
262
+ // the ceiling flat whatever the table's width. An explicit rowsPerGroup (a
263
+ // test, a caller who knows) is obeyed as given.
264
+ export const CELL_BUDGET = 8_000_000;
265
+ export const DEFAULT_ROWS_PER_GROUP = 20_000;
266
+
267
+ function groupFor(columns, requested) {
268
+ if (requested != null) return requested;
269
+ return Math.max(500, Math.min(DEFAULT_ROWS_PER_GROUP, Math.floor(CELL_BUDGET / Math.max(1, columns.length))));
270
+ }
271
+
272
+ // Written under a temporary name and moved into place only once the file is
273
+ // complete. The writer streams straight to disk, so a throw partway through
274
+ // would otherwise leave a truncated file sitting at the canonical name — every
275
+ // later read of which fails with "not a parquet file", and the previous good
276
+ // export of that session is already gone. A partial answer under the name of a
277
+ // whole one is the worst of the three outcomes.
278
+ function rowSink({ file, columns, rowsPerGroup }) {
279
+ const partial = `${file}.partial`;
280
+ const writer = fileWriter(partial);
281
+ const pq = new ParquetWriter({ writer, schema: schemaElements(columns) });
282
+ // Name → column position, so a row is placed by index rather than by looking
283
+ // each of the table's columns up on it. The table is WIDE and each row is
284
+ // narrow — 443 columns, ~49 of them set on any given entity — so reading
285
+ // every column name off every row costs a property lookup for the ~90% that
286
+ // are absent. Filling nulls and writing only what is there is the same table
287
+ // for about a third of the work.
288
+ const at = new Map(columns.map((c, i) => [c.name, i]));
289
+ let batch = columns.map(() => []);
290
+ let rows = 0;
291
+ const flush = () => {
292
+ if (!batch[0].length) return;
293
+ pq.write({
294
+ columnData: columns.map((c, i) => ({ name: c.name, data: batch[i] })),
295
+ // One group per batch: the batch IS the group, so the writer is never
296
+ // asked to hold more than what has already been handed to it.
297
+ rowGroupSize: batch[0].length,
298
+ });
299
+ batch = columns.map(() => []);
300
+ };
301
+ return {
302
+ push(row) {
303
+ const index = batch[0].length;
304
+ for (let i = 0; i < columns.length; i++) batch[i].push(null);
305
+ for (const name in row) {
306
+ const i = at.get(name);
307
+ if (i !== undefined && row[name] !== undefined) batch[i][index] = row[name];
308
+ }
309
+ rows += 1;
310
+ if (batch[0].length >= rowsPerGroup) flush();
311
+ },
312
+ finish() {
313
+ flush();
314
+ pq.finish();
315
+ renameSync(partial, file);
316
+ return rows;
317
+ },
318
+ // Called when the run did not get that far. Best-effort: a leftover
319
+ // `.partial` is untidy, an unreadable `.parquet` is a wrong answer.
320
+ abandon() {
321
+ try { rmSync(partial, { force: true }); } catch { /* nothing to clean up */ }
322
+ },
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Where the engine's headless replay door lives, in the engine THIS GAME is
328
+ * pinned to.
329
+ *
330
+ * Imported from the resolved bundle rather than by a relative path, for the
331
+ * same reason the recording's reassembler is: a game can sit on a new CLI and
332
+ * an older engine, and the honest answer to that is a sentence saying so — not
333
+ * a module-resolution stack trace that reads, to the person holding it, as "my
334
+ * recording is broken".
335
+ */
336
+ async function loadOpenReplay(sharedDir) {
337
+ const at = join(sharedDir, 'framework', 'replay-open.mjs');
338
+ try {
339
+ return (await import(pathToFileURL(at).href)).openReplay;
340
+ } catch (e) {
341
+ throw new Error(
342
+ "this game's pinned engine cannot open a recording headlessly, so there is nothing to export — "
343
+ + `run \`looop update\` and try again (${e?.message ?? e})`,
344
+ );
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Export one recorded session as two Parquet tables.
350
+ *
351
+ * The state table is the expensive one and the reason this exists: a recording
352
+ * holds inputs, so the only way to know where anything WAS is to run the game
353
+ * forward again. Doing that once and writing the result down is what turns
354
+ * every later question into SQL instead of another replay.
355
+ *
356
+ * @param {object} opts
357
+ * @param {string} opts.dir - the game folder
358
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
359
+ * @param {string} opts.outDir - where the .parquet files go
360
+ * @param {string} [opts.sharedDir] - the resolved engine bundle's shared/ tree
361
+ * @param {number} [opts.rowsPerGroup]
362
+ * @param {string} [opts.engineVersion] - the engine this export is running on,
363
+ * compared against the one that recorded the session
364
+ * @param {(o: object) => Promise<object>} [opts.openReplayImpl] - seam for tests
365
+ * @param {(msg: string) => void} [opts.log]
366
+ */
367
+ export async function exportSession({
368
+ dir, stream, outDir, sharedDir, rowsPerGroup, engineVersion, openReplayImpl, log = () => {},
369
+ }) {
370
+ const openReplay = openReplayImpl ?? await loadOpenReplay(sharedDir);
371
+ // The engine's own warnings — a recording missing segments, a session tuned
372
+ // live mid-play — are written to this sink. Without it they go to the floor
373
+ // and the export looks clean while describing a world that is not the one
374
+ // that was played.
375
+ const opened = await openReplay({ dir, stream, log });
376
+ const { replayer, bundle, session } = opened;
377
+ const chosen = opened.stream ?? stream ?? null;
378
+
379
+ // Which engine wrote this recording, against the one about to replay it. The
380
+ // header has carried engineVersion since the first recording and nothing read
381
+ // it, so the only way a reader learned the two disagreed was a divergence —
382
+ // which does not fire if the change altered nothing the checksums cover.
383
+ //
384
+ // Reported before the replay runs, not after: the replay is the slow part and
385
+ // this answer is already known. An absent version is UNKNOWN, not different —
386
+ // every recording made before this shipped has none, and warning on those
387
+ // would fire on every old session still on disk.
388
+ // A recording's header is NOT first-party text. Segments reach the dev server
389
+ // over a lane that deliberately does not require an Origin (a multiplayer
390
+ // room's worker sends none), and the store validates only the four fields it
391
+ // routes on — format, seq, stream, events. Everything else is spread through
392
+ // the stitcher verbatim, so anything printed from a header is attacker-
393
+ // reachable by whoever can post a segment.
394
+ //
395
+ // Printing is what makes that matter. `ESC[2J ESC[H` clears the screen and
396
+ // repaints, and the line being repainted here is the one a creator — or the
397
+ // coding agent reading their terminal — uses to decide whether to trust the
398
+ // exported numbers. An export with no session named takes the newest
399
+ // recording, so a segment posted a moment ago is the default target.
400
+ //
401
+ // So: REJECT, don't sanitize. A value that is not a plain version-or-digest
402
+ // token is treated as unknown, which is a state this code already handles
403
+ // honestly — and an unreadable field is genuinely unknown, not a mismatch.
404
+ // The charset covers semver including pre-release labels (`0.2.0-riven`) and
405
+ // hex digests, and admits no control character, escape or whitespace.
406
+ const TOKEN = /^[A-Za-z0-9._+-]{1,64}$/;
407
+ // `__PUBLISH_VERSION__` is the placeholder the release build substitutes. A
408
+ // room served straight off a checkout never went through that build, so every
409
+ // session recorded under `looop dev` carries the token verbatim — reading it
410
+ // as a version would report a mismatch on every single dev export, and a
411
+ // warning that fires every time is one nobody reads by the third time.
412
+ const known = (v) => (typeof v === 'string' && TOKEN.test(v) && v !== '__PUBLISH_VERSION__' ? v : null);
413
+ const recordedEngine = known(session?.header?.engineVersion);
414
+ const runningEngine = known(engineVersion);
415
+ if (recordedEngine && runningEngine && recordedEngine !== runningEngine) {
416
+ log(` ⚠ this session was recorded by engine ${recordedEngine} and is being replayed on `
417
+ + `${runningEngine}. A replay runs the code as it is now, so where the two engines `
418
+ + 'simulate differently the table holds a world nobody played.');
419
+ }
420
+ // And the same question for the game's own code, which is the half that
421
+ // actually moves. engineVersion changes once per release; a fingerprint
422
+ // changes the moment a tick does.
423
+ // Same provenance, same rule: this one comes off the header too.
424
+ const recordedCode = known(session?.header?.codeFingerprint);
425
+ const runningCode = known(bundle?.fingerprint);
426
+ const codeChanged = Boolean(recordedCode && runningCode && recordedCode !== runningCode);
427
+ if (codeChanged) {
428
+ log(' ⚠ this game has changed since the session was recorded (the simulation it lowers to '
429
+ + `is ${runningCode}; the recording was made by ${recordedCode}). The table below is what `
430
+ + 'your game does with those inputs TODAY, which is not what happened when you played.');
431
+ }
432
+
433
+ const { columns, conflicts } = tableSchema(bundle);
434
+ if (conflicts.length) {
435
+ log(`note: ${conflicts.length} field name(s) are declared with different types by different entity kinds `
436
+ + `and ride as JSON text — ${conflicts.join(', ')}`);
437
+ }
438
+ const columnIndex = new Map(columns.map((c) => [c.name, c.type]));
439
+
440
+ mkdirSync(outDir, { recursive: true });
441
+ const stateFile = join(outDir, `${chosen ?? 'session'}.state.parquet`);
442
+ const inputFile = join(outDir, `${chosen ?? 'session'}.inputs.parquet`);
443
+
444
+ const sink = rowSink({ file: stateFile, columns, rowsPerGroup: groupFor(columns, rowsPerGroup) });
445
+ const tally = { undoubleable: 0 };
446
+ let ticks = 0;
447
+ let rows;
448
+ try {
449
+ // The world BEFORE any step is a real tick of the session — it is the
450
+ // keyframe the replay entered at — so it is written before the loop, not
451
+ // skipped into it.
452
+ for (;;) {
453
+ const { tick, entities } = replayer.runtime.serializeEntities();
454
+ for (const entity of entities) sink.push(stateRow(entity, tick, columnIndex, tally));
455
+ ticks += 1;
456
+ if (replayer.atEnd) break;
457
+ replayer.step();
458
+ }
459
+ rows = sink.finish();
460
+ } catch (e) {
461
+ sink.abandon();
462
+ throw e;
463
+ }
464
+
465
+ // Streamed like the state table rather than materialised: the row count comes
466
+ // from a recording, and a recording is not this machine's to trust.
467
+ const inputs = inputSchema(session);
468
+ const inputSink = rowSink({ file: inputFile, columns: inputs.columns, rowsPerGroup: groupFor(inputs.columns, rowsPerGroup) });
469
+ let inputRows;
470
+ try {
471
+ for (const send of inputs.sends) inputSink.push(inputRow(send, inputs.keys, inputs.types));
472
+ inputRows = inputSink.finish();
473
+ } catch (e) {
474
+ inputSink.abandon();
475
+ throw e;
476
+ }
477
+ if (inputs.truncatedKeys) {
478
+ log(`note: ${inputs.truncatedKeys} rarely-used payload field(s) did not get their own column `
479
+ + `(the cap is ${MAX_PAYLOAD_COLUMNS}) — they are still in the \`payload\` JSON column on every row`);
480
+ }
481
+
482
+ return {
483
+ stream: chosen,
484
+ stateFile,
485
+ inputFile,
486
+ ticks,
487
+ rows,
488
+ columns: columns.length,
489
+ inputs: inputRows,
490
+ truncatedKeys: inputs.truncatedKeys,
491
+ // Three separate ways the table can describe a world that is not the one
492
+ // that was played, kept apart because they have different causes and
493
+ // different answers.
494
+ divergences: replayer.divergences?.length ?? 0,
495
+ gaps: replayer.gaps?.length ?? 0,
496
+ unsupported: replayer.unsupported?.length ?? 0,
497
+ // How many times playback was put back onto the recorded state by a
498
+ // keyframe. This is the counterweight to `divergences`: it is what bounds a
499
+ // drift to one keyframe interval instead of the rest of the session, so a
500
+ // reader deciding whether to trust the table needs both numbers.
501
+ reanchors: replayer.reanchors?.length ?? 0,
502
+ // Counts alone cannot say whether the damage was undone: a re-anchor at
503
+ // tick 4 tells a reader nothing about a divergence at tick 900. These two
504
+ // let the caller compare the last drift against the last correction after
505
+ // it, which is the question actually being asked.
506
+ lastDivergenceTick: replayer.divergences?.at(-1)?.tick ?? null,
507
+ lastReanchorTick: replayer.reanchors?.at(-1)?.tick ?? null,
508
+ recordedEngine,
509
+ runningEngine,
510
+ recordedCode,
511
+ runningCode,
512
+ codeChanged,
513
+ undoubleable: tally.undoubleable,
514
+ };
515
+ }